Compare commits

...

32 Commits

Author SHA1 Message Date
Maurice 8f64431ff1 refactor(auth): one helper finds a backup code for login, reset and MFA disable
Keeps auth.service.ts inside its size baseline: the recovery-code check the
disable path gained is the lookup the password reset already spelled out.
2026-10-18 12:35:37 +02:00
Maurice c378722d63 test(auth): read the disabled MFA state through the ORM, formatted 2026-10-18 12:11:41 +02:00
Maurice bfec874d04 fix(i18n): mention backup codes in the disable-2FA hint for every locale 2026-10-18 11:47:11 +02:00
Jens Kowalsky a36019b7e2 fix(auth): allow backup codes for TOTP recovery 2026-10-18 11:47:10 +02:00
Maurice bda24ec639 fix(tours): description on its own line, fewer and whole badges in the tour list
The tour list row puts the description between the name and the badges.
The badges keep the three that matter most (time, then a paddle tour's
check bench and distance, or a hike's distance and climbing) and only
those that fit whole, so a narrow sidebar shows two instead of cutting
one off. A check bench without obstacle data shows no pill in the list
or the day plan; the checklist still names the gap.
2026-10-17 15:27:51 +02:00
Maurice 09caf4bc3f fix(tours): sorts that name their order, the note focused without autofocus
The checklist and the obstacle data sorted texts without a compare
function. They now sort by code units through one shared compareCodeUnits,
the order a bare sort gave, the same on every machine; the material data of
a lock's parts no longer orders by the server's locale, so its hash cannot
change with it. The note editor takes the focus through a ref instead of
autoFocus, a direct function reference in a map becomes a call, and the
GPX import's default fields are a named constant.
2026-10-17 03:18:04 +00:00
Maurice 9272151c07 fix(tours): pending checklist saves per tour, a queued checklist waits for its replay
A save answered on one tour no longer clears the pending state of a save
started on another. A checklist changed here offline waits for the queue to
replay before it is read online, as a bench of such a write already did. A
saved tour the list has not loaded yet is no unsaved paddle draft, and Next
walks the rows as the list shows them, a vanished object among them.
2026-10-17 02:31:01 +00:00
Maurice 0105891355 fix(tours): a checklist cut short at its caps says so, also when an earlier decision tips it over 2026-10-17 02:31:01 +00:00
Maurice a7f5a114e6 fix(tours): the checklist keeps to its tour, its state and its focus
A decision answered after another tour was opened no longer replaces that
tour's checklist. A decision made offline on a checklist already older than
the route stays marked as such, and once online a bench from a write made
offline waits for the queue to replay, then takes in the server's. While
the checklist reads again it stays in view, locked, so a panel open on the
map keeps its note. A saved tour turned into a paddle tour asks to be saved
first, a vanished obstacle stays among the rows, Next follows the list
order and keeps the focus in the panel, a note closes only once saved, the
earlier choice in review is named for screen readers too, planning a
portage is offered to editors only, and the map card scrolls within the
screen. A tooltip of several lines breaks at them. A page test waits for
the tours row instead of reading it at once.
2026-10-17 02:07:22 +00:00
Maurice 2a4b71b3d9 fix(tours): checklist entries within their contract, whole locks and a leaner reading
A lock flight chained into one group of more than 30 parts broke the
checklist's contract and made every decision about it fail; an entry now
names its nearest part and the first others, a portage lists at most 30
obstacles and a checklist at most 100 own entries. Reading along a line
takes in the parts of a lock the corridor cut off, so its data basis no
longer depends on how the route runs. A line or an area stands at the
middle of its extent, so adding a node leaves it in place, and an earlier
decision is judged by where the obstacle was, not by the route point of its
passage. The cache keeps its readings without their lines, for one data
version at a time, and the data version is read without the region
boundaries.
2026-10-17 02:07:22 +00:00
Maurice d46b6fb646 refactor(tours): the map stage's track pick and the planner's focus points in hooks of their own
The tour map stage and the trip planner page hook had grown past the
complexity and length the lint ratchet holds them to. Marking a spot on a
recorded paddle track moves into useTrackLinePick, and what the planner map
frames into usePlannerFocusPoints, each with its own tests. The planner's
viewport snapshot no longer reads a ref while rendering, so the count of
that warning goes down by one.
2026-10-17 02:07:22 +00:00
Maurice 4a64fc9d21 docs(wiki): the tours MCP tools name a paddle tour's check bench and checklist 2026-10-17 01:16:41 +00:00
Maurice 5ee8d450a9 refactor(i18n): the checklist's drawing hint goes, nothing in the planner shows it 2026-10-17 01:16:41 +00:00
Maurice de86e6383a feat(tours): the obstacle checklist, the decision panel and the safety note in the planner
PAD-06 and PAD-09 on screen. A paddle tour open in the planner or shown as
recorded carries a checklist card: the obstacles of the data along its
route in route order with the paddler's own portages and locks among them,
each with its status, its paddling rule and the SUP advice at a lock, and
the earlier decisions the route or the data no longer carries behind a
fold. A row moves the camera onto the obstacle and the other parts of its
lock and opens its card on the map, where the decision panel sits: four
choices, the notices that stay after a choice (a ban, the SUP advice, a
portage still to plan), the date of the last check on site, a note and who
decided when. An object the map does not hold opens in a dialog instead.
Deciding waits while the route has unsaved changes, and a stale or dirty
list is dimmed, never hidden.

The tours list, the desktop day plan and the phone day plan show the bench
as one pill, the tour detail shows it with the data's date, and every paddle
tour reaches the safety note from its footer. Nothing reads as safe where
TREK knows nothing, and nothing blocks.
2026-10-17 01:16:41 +00:00
Maurice 14b917ee97 test(tours): a checklist cut short past its cap, and a decision save failing part way stores nothing 2026-10-17 00:31:29 +00:00
Maurice 7fffd4300f feat(tours): the paddle checklist through the offline core on the client
PAD-06 and PAD-09 on the client's data side. A tour's cached row keeps its
checklist beside its provenance, written through one cache helper by the
repository and the queue replay, which also keeps provenance an answer
lacks. The checklist is read for the bench the list shows and served from
the cache while its revision matches. Decisions made offline, or behind a
queued write of the same tour, change the cached checklist and bench at
once under a local revision and replay with their idempotency key; a route
written offline marks the checklist stale until the server judged it.

The obstacle store learns a camera focus: a click in the rail moves the
camera onto an obstacle and the other parts of its lock, the newest of such
a focus and a picked search result wins, and a selected lock lights up
whole on both maps. The checklist hook and its model hold the logic the
cards will show.
2026-10-17 00:29:26 +00:00
Maurice fa8272c522 feat(i18n): the paddle checklist and safety strings in every language
The checklist, its statuses, choices and notices, and the safety note of a
paddle tour, translated into all 27 languages. A spec keeps the checklist
strings in English and German from claiming the water is safe, cleared or
passable. The paddle strings of the last batch that Prettier had not seen
are formatted.
2026-10-17 00:29:18 +00:00
Maurice c40d00b830 feat(tours): a paddle tour's obstacle checklist and check bench on the server
PAD-06 and PAD-09. The tours list carries the check bench of every paddle
tour and the detail its whole checklist, worked out of the obstacle data
along the tour's line and the decisions stored for it. The obstacle data is
read along the line run by run and kept in memory under the line, the boat
and the data version, so a list of a trip reads it once; a hike costs
nothing. While the data cannot be read the checklist says so and keeps every
decision visible.

PUT /api/trips/:tripId/tours/:placeId/obstacle-reviews stores what the
paddlers decided, each against the basis the user saw, and answers the
detail. It needs place_edit, refuses a tour that is no paddle tour with a
400, waits with a 503 while the data cannot be read, and skips an obstacle
no longer on the checklist instead of failing. A copied tour and a copied
trip take their decisions along. The MCP read tools describe the new fields;
there is no write tool, since a decision is a statement of the paddlers.

The obstacle data keeps the dates of checks on site, covers a line by
sampling it, and tells its version by its rows and regions.
2026-10-17 00:29:18 +00:00
Maurice 86976aef10 feat(tours): the contracts and the pure core of the paddle obstacle checklist
PAD-06 and PAD-09 in shared. A paddle tour's checklist lists the obstacles
of the OpenStreetMap data along its route, each bound to a basis: the route
near the obstacle as samples around every passage, and the material data of
the obstacle as a hash and its position. A decision keeps that basis, so it
reads as one to check again once the route or the data changed there, and a
portage decision holds while the paddler's own portage carries round every
passage. The check bench sums the list up for the tours list and the day
plan. Nothing in it is a safety or legal clearance.

The route relation learns the passages of a route by an object and the
distance of a point to a route. The client takes the obstacle key from
shared instead of its own copy.
2026-10-17 00:29:10 +00:00
Maurice c246877f30 refactor(tours): the region repository reads the status enum of its entity, unused exports go
PaddleRegionsRepository types and writes the status through the generated PaddleRegionsStatus instead of a copy of its four values; the paint tokens, a fact key type and a minutes input stay private to their modules, the wind test pins its 502 text, and the planner page's size entry follows it down.
2026-10-16 23:24:20 +00:00
Maurice 1e2ba0f842 feat(i18n): the paddle portage, time, wind, obstacle and region strings in every language
The strings of the paddle plan, the time estimate, the wind card, the obstacle card and notice and the admin's paddle water data, translated into the 25 locales that still lacked them, with the plural forms each language needs. The pinned count of Tours keys follows.
2026-10-16 23:22:09 +00:00
Maurice 64e413330e test(tours): cover the paddle time card, the wind card and the marks on a recorded paddle track
The time card's estimate with water, portages and locks and a lock without times leaving it incomplete, every state of the wind card with day switching and missing values that never read as calm, and the track marks with their tools, minutes, removal and saving through the repo.
2026-10-16 23:20:46 +00:00
Maurice 2e3fd4fc4c test(admin): cover the paddle regions hook: polling while a region is read, the debounced catalogue search, the actions and offline 2026-10-16 23:14:26 +00:00
Maurice 1c38d34699 docs(wiki): Tours in the admin addons, with setting up the paddle water data
How an admin adds a region from Geofabrik's list, what the card shows while it is read, the monthly refresh on the 3rd, what a failed read keeps, and the OpenStreetMap attribution under the ODbL; a record in the data is no check on site.
2026-10-16 23:12:46 +00:00
Maurice ccfb610aab test(tours): cover the paddle obstacle card, map layers, route band, notice, sync, repos and the admin regions panel
Thirteen client suites for the paddle obstacle work and the region admin: the detail model and card, the overlay with its outside press and Escape, the Leaflet and GL layers with the area zoom threshold and the single click handler, the route band marks, every state of the obstacle notice, the sync hook, the tiled obstacle repo, the online only region repo, and the regions panel. The wind hook for the start of a paddle tour is covered too.
2026-10-16 23:12:41 +00:00
Maurice 03d6457bde feat(tours): paddle obstacle card, admin regions panel and obstacle marks on the route band
PAD-05B: a click on an obstacle on the map or on the route band opens its card in the look of the via ferrata card: the kind with its glyph, the name, how it stands to the route (on it at a distance along it, near it with the link marked unclear, or only in the area), the paddling rule its tags give with canoe read before boat, a condition as written, height, grade, hours and fees, the source with the date of the data and the OpenStreetMap link. A missing rule reads as unknown, never as allowed, and a portage way recorded as private says so. Objects only in the area show from zoom 13 on, on Leaflet and GL alike; on GL the lines only near the route are dashed as on Leaflet, one click handler picks the marker over the line, and a picked marker no longer flickers. The route band marks the obstacles on and near the route by their share of the measured line. The obstacle notice also names an answer cut at its limit.

PAD-05A: the admin area gets a Paddle water data card under the addons, on desktop and phone: a search in Geofabrik's list with each region's path, the regions with their state (waiting, reading with its progress, ready with objects and data date, failed with the reason), reading again and removing after a confirmation. The list follows a read without reloading. Region changes write audit rows.

Map colours of paddle objects and portages come from theme tokens read at draw time, as the via ferratas do; the ferrata layer now shares that reader. The rail takes the planner page's parts as one bag, the map hit tests of the tour layers are one helper, and the unused noWaterData string is gone from every locale.
2026-10-16 23:03:09 +00:00
Maurice 89ad4ec56a fix(tours): paddle data reads stream, queue safely and keep their coverage during a refresh
The PBF reader streams the extract and refuses a varint longer than ten bytes; the block offsets are read as the plain int64 the format defines. Region reads queue without loose promises: a region queued while another is read is still picked up, removing a region calls its read off, a shutdown stops the read under way and the next boot queues it again and clears the download folder. The admin list shows how far a read has come.

Geofabrik is asked only on its own servers, redirects included, and an outage of its list answers 502 instead of an unknown region. Nested ids such as us/texas are accepted.

A region being read again or after a failed read still counts with the data it holds, a region without a boundary spans its objects, and an object two overlapping regions hold is answered once. Stored geometry, tags and boundaries go through the JSON column declarations. An obstacle is related to the route both ways, so a long weir passed in its middle and a route inside an area count.

Tests cover the reader with extracts written in the test, the tag rules, the extraction and lock grouping, the geo helpers, the Geofabrik client, the region queue, the read side, both controllers, the job, the repositories and the HTTP routes; the domain gets its coverage entry.
2026-10-16 22:27:29 +00:00
Maurice 2c3b20bb57 feat(tours): paddle portages and locks, paddle time, wind at the start and obstacle data from OpenStreetMap
PAD-03 portages and locks on planned tours and recorded tracks, PAD-07 paddle time with portage and lock stops, PAD-08 wind forecast for the start, PAD-05A regional obstacle import from Geofabrik extracts with admin endpoints and a monthly refresh, PAD-05B first cut of the obstacle layer on Leaflet, GL and 3D. Work in progress: checklist (PAD-06), check bench (PAD-09), admin UI, translations and tests follow.
2026-10-16 23:44:58 +02:00
Maurice d705bd9dd0 test(tours): cover the seamark overlay on GL maps and the tour type fields 2026-10-16 23:14:52 +02:00
Maurice 704621d0c2 refactor(tours): move the detail view's measures into their own component 2026-10-16 23:14:52 +02:00
Maurice 9494521ac7 feat(tours): plan paddle tours, hand-drawn legs, reverse and return to start, maps per tour type
- Paddle as a plannable tour type: one boat (kayak, canoe, SUP, other), river or
  lake, a pace that a boat change never overwrites; time is distance over pace
- Hand-drawn legs beside routed ones, drawn dotted and labelled as not routed,
  kept through save, reopen and edits elsewhere
- Reverse a tour and close it back to its start, each one undo step
- GPX import shows a preview first (line, measures, duplicates, type) and only
  the confirmation stores the tour
- Base maps and overlays per tour type in the planner: hike keeps topo and the
  via ferrata layer, paddle gets the OpenSeaMap seamark overlay
2026-10-16 23:14:52 +02:00
Maurice 33b006014b fix(settings): typing in the wanderer address and key fields no longer gets undone
useToast handed back a new object on every render. useWandererConnection lists
toast in its load effect, so each keystroke reloaded the saved settings and
reset both fields to them.
2026-10-16 11:33:19 +02:00
381 changed files with 40469 additions and 936 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"trek/no-swallowed-catch": 133,
"max-params": 35,
"trek/store-without-selector": 69,
"react-hooks/refs": 560,
"react-hooks/refs": 559,
"react-hooks/set-state-in-effect": 197,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121,
+3 -3
View File
@@ -1,13 +1,13 @@
{
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1916,
"src/api/client.ts": 1910,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1409,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2371,
"src/components/Map/MapViewGL.tsx": 2299,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
@@ -24,7 +24,7 @@
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1147,
"src/pages/TripPlannerPage.tsx": 1142,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
+1 -6
View File
@@ -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 = {
+16
View File
@@ -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)),
};
+32
View File
@@ -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)),
};
+19
View File
@@ -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)),
};
+14
View File
@@ -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)),
};
+12
View File
@@ -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),
};
+10
View File
@@ -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)),
};
@@ -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 (
+8 -17
View File
@@ -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);
}
+64 -3
View File
@@ -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"
+4 -4
View File
@@ -8,7 +8,6 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
import HazardLayers from './HazardLayers'
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet'
import { formatViaDwell } from './routeViaDwell'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
@@ -68,6 +67,7 @@ import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
@@ -831,7 +831,7 @@ export const MapView = memo(function MapView({
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
}: MapViewProps) {
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
@@ -1291,7 +1291,7 @@ export const MapView = memo(function MapView({
/>
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
<FerrataMapLayerLeaflet layer={ferrataLayer} />
<TourMapLayersLeaflet ferrataLayer={ferrataLayer} overlays={tourLayers?.overlays} paddle={tourLayers?.paddle} />
{/* The route as it was actually recorded (#2279). Drawn in the casing
pane's sibling order so it sits under the planned route rather than
@@ -1339,7 +1339,7 @@ export const MapView = memo(function MapView({
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} />
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
</div>
</div>
+8 -79
View File
@@ -8,8 +8,9 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { applySatellite } from './glRasterLayers'
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { MapLockPill } from './MapLockPill'
import { useMarkerThumbs } from './useMarkerThumbs'
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
@@ -32,7 +33,7 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { groupCoincidentPlaces } from './coincidentPlaces'
import { useHazardLayerGL } from './useHazardLayerGL'
import { type FerrataGlMap, isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL'
import type { FerrataGlMap } from '../Ferratas/useFerrataLayerGL'
import { formatViaDwell } from './routeViaDwell'
import { useDawarichTrailGL } from './useDawarichTrailGL'
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
@@ -44,7 +45,7 @@ import { poiPinParts } from './poiMarker'
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
@@ -65,25 +66,6 @@ const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
* layer of its own, drawn over the style and under everything TREK adds — the route,
* the pins and the tracks stay on top and stay legible, which is the same order the
* Leaflet map ends up with.
*
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
*/
const SATELLITE_SOURCE_ID = 'trip-satellite'
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
// route source so they sit under the current route, which also puts them before the first
// `trip-` layer, and anchoring the imagery there painted right over them.
const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-']
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -201,59 +183,6 @@ function addPlaceClusterLayers(map: any): void {
})
}
/**
* Puts the imagery on the map, or takes it off again.
*
* Built on demand rather than once at load: a style change drops every source the map
* had, and a hot reload does the same. Visibility comes first: a layer that is already
* there only needs flipping, so the usual pass is one call and a tap answers at once.
*
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
* the usual state, and the only retry here is `styledata`, which fires on style edits
* and not when those finish. Behind that gate a stored choice met a busy map on load,
* and a tap on the road trip stage met the sources the same render had just set again,
* so the imagery was never drawn. The gate was also the wrong question: this only runs
* after `load`, and addSource and addLayer refuse only a style document that is not in
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applySatellite(map: any, on: boolean, topo = false): void {
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID
try {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none')
return
}
if (!on) return // Nothing to build while it is switched off.
// The layer is what is missing, not necessarily the source: a pass that got the
// source in and then failed on the layer would otherwise leave a source that stops
// every later pass from ever building the layer.
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'raster',
tiles: topo
? ['a', 'b', 'c'].map(subdomain => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
: [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
})
}
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? []
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: layerId,
type: 'raster',
source: sourceId,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
} catch { /* a style that refuses the layer keeps the plain basemap */ }
}
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
@@ -624,7 +553,7 @@ export function MapViewGL({
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
roadtripVias,
onMoveVia,
onRemoveVia,
@@ -683,7 +612,7 @@ export function MapViewGL({
const mapRef = useRef<any | null>(null)
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
useFerrataMapLayerGL(mapReady ? readyMap : null, ferrataLayer)
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays, tourLayers?.paddle)
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1500,7 +1429,7 @@ export function MapViewGL({
&& typeof map.queryRenderedFeatures === 'function'
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
if (isFerrataHit(map, e.point)) return // a picked via ferrata opens its card instead
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
})
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
@@ -2311,7 +2240,7 @@ export function MapViewGL({
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange}
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} />
: <MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />}
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
@@ -0,0 +1,50 @@
import { TileLayer } from 'react-leaflet';
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet';
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
import { PaddleLayerLeaflet } from '../Tours/paddle/PaddleLayerLeaflet';
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
import type { MapRasterOverlay } from './mapRasterOverlay';
/** Over the basemap's tiles (z-index 1) and every basemap raster, under the route's own panes. */
const OVERLAY_Z_INDEX = 300;
/** The overlays on the Leaflet map: a tile layer each while it is on, nothing while it is off. */
function RasterOverlaysLeaflet({ overlays }: { overlays?: readonly MapRasterOverlay[] }) {
return (
<>
{overlays
?.filter((overlay) => overlay.active)
.map((overlay) => (
<TileLayer
key={overlay.id}
url={overlay.url}
attribution={overlay.attribution}
minZoom={overlay.minZoom}
maxNativeZoom={overlay.maxZoom}
maxZoom={22}
zIndex={OVERLAY_Z_INDEX}
eventHandlers={{ tileerror: overlay.onTileError, tileload: overlay.onTileLoad }}
/>
))}
</>
);
}
/** The tour planner's layers on the Leaflet map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */
export function TourMapLayersLeaflet({
ferrataLayer,
overlays,
paddle,
}: {
ferrataLayer?: FerrataMapLayer;
overlays?: readonly MapRasterOverlay[];
paddle?: PaddleMapLayer;
}) {
return (
<>
<FerrataMapLayerLeaflet layer={ferrataLayer} />
<RasterOverlaysLeaflet overlays={overlays} />
<PaddleLayerLeaflet layer={paddle} />
</>
);
}
@@ -0,0 +1,92 @@
import {
OPENTOPOMAP_TILE_ATTRIBUTION,
OPENTOPOMAP_TILE_MAXZOOM,
OPENTOPOMAP_TILE_URL,
SATELLITE_TILE_ATTRIBUTION,
SATELLITE_TILE_MAXZOOM,
SATELLITE_TILE_URL,
} from '../../constants/mapDefaults';
/*
* The raster layers a GL map draws between its basemap style and what TREK
* adds on top: the satellite and topo base layers, and (in mapRasterOverlay.ts)
* the transparent overlays such as the seamarks.
*/
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
* layer of its own, drawn over the style and under everything TREK adds — the route,
* the pins and the tracks stay on top and stay legible, which is the same order the
* Leaflet map ends up with.
*
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
*/
const SATELLITE_SOURCE_ID = 'trip-satellite';
const SATELLITE_LAYER_ID = 'trip-satellite-raster';
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
// route source so they sit under the current route, which also puts them before the first
// `trip-` layer, and anchoring the imagery there painted right over them.
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
/**
* Puts the imagery on the map, or takes it off again.
*
* Built on demand rather than once at load: a style change drops every source the map
* had, and a hot reload does the same. Visibility comes first: a layer that is already
* there only needs flipping, so the usual pass is one call and a tap answers at once.
*
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
* the usual state, and the only retry here is `styledata`, which fires on style edits
* and not when those finish. Behind that gate a stored choice met a busy map on load,
* and a tap on the road trip stage met the sources the same render had just set again,
* so the imagery was never drawn. The gate was also the wrong question: this only runs
* after `load`, and addSource and addLayer refuse only a style document that is not in
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function applySatellite(map: any, on: boolean, topo = false): void {
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID;
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID;
try {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
return;
}
if (!on) return; // Nothing to build while it is switched off.
// The layer is what is missing, not necessarily the source: a pass that got the
// source in and then failed on the layer would otherwise leave a source that stops
// every later pass from ever building the layer.
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'raster',
tiles: topo
? ['a', 'b', 'c'].map((subdomain) => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
: [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
});
}
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? [];
const firstOwn = layers.find((l: { id: string }) => OWN_LAYER_PREFIXES.some((prefix) => l.id.startsWith(prefix)));
map.addLayer(
{
id: layerId,
type: 'raster',
source: sourceId,
paint: { 'raster-opacity': 1 },
},
firstOwn?.id
);
} catch {
/* a style that refuses the layer keeps the plain basemap */
}
}
@@ -8,3 +8,11 @@
* exactly that reason — the others were the ones that needed to match it.
*/
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
/** The frosted glass of the map's floating cards (a ferrata's, a paddle obstacle's), so they read as its controls. */
export const MAP_CARD_GLASS = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}
+49
View File
@@ -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 -1
View File
@@ -6,7 +6,7 @@ import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays';
import type { DayBoundaryControls } from './dayBoundaryDrag';
import type { CompassMap } from './MapCompassPill';
import type { MapPlaceTool, TourBaseLayer } from './MapLayerSwitcher';
import type { MapPlaceTool, TourBaseLayer, TourLayerOffer } from './MapLayerSwitcher';
import type { Poi } from './poiCategories';
/*
@@ -129,6 +129,11 @@ export interface MapViewProps {
* Ferratas modules.
*/
ferrataLayer?: FerrataMapLayer;
/**
* What the tour's type offers in the tour layer pill (P2B-06): its base layers,
* and transparent overlays drawn over the basemap and under the route.
*/
tourLayers?: TourLayerOffer;
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
@@ -0,0 +1,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]);
}
@@ -7,6 +7,7 @@ import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
import { movingTimeLabel } from '../Tours/tourFacts';
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
import { TimePill } from './planParts';
@@ -35,9 +36,10 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
<TimePill>
<span
data-testid="trip-plan-tour-walking-time"
aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
aria-label={`${movingTimeLabel(tourTimes.tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
>
{t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
{movingTimeLabel(tourTimes.tour, t('tours.planner.walkingShort'), t)}:{' '}
{formatPlannedTourDuration(tourTimes.walkingMinutes)}
</span>
</TimePill>
)}
@@ -308,9 +308,10 @@ export default function AccountTab(): React.ReactElement {
/>
<input
type="text"
inputMode="numeric"
inputMode="text"
autoComplete="one-time-code"
value={mfaDisableCode}
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
placeholder={t('settings.mfa.codePlaceholder')}
aria-label={t('settings.mfa.codePlaceholder')}
className={`${INPUT} font-geist tabular-nums`}
@@ -1,8 +1,9 @@
import type { TourListItem } from '@trek/shared';
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest';
import { paddleBench } from '../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import TourDayRowFacts, { TourDayRowIcon } from './TourDayRowFacts';
import TourDayRowFacts, { TourDayRowIcon, TourDayRowPills } from './TourDayRowFacts';
import { tourDayRowIcon } from './tourDayRowIcon';
const tour: TourListItem = {
@@ -74,3 +75,72 @@ describe('RC-01 Tour day-row facts', () => {
expect(openRow).not.toHaveBeenCalled();
});
});
describe('PAD-09 the check bench on a tour row', () => {
const kayak: TourListItem = { ...tour, tour_type: 'kayak', paddle_check: paddleBench() };
it('PCD-001: a kayak tour with a bench shows one checklist pill, its tooltip saying it is no clearance', () => {
render(<TourDayRowPills tour={kayak} />);
const pill = screen.getByTestId('plan-tour-paddle-check');
expect(pill).toHaveTextContent('1 open');
expect(pill.getAttribute('aria-label')).toContain('A checklist is no safety or legal clearance.');
});
it('PCD-002: an area without obstacle data shows no pill at all, never a count', () => {
render(<TourDayRowPills tour={{ ...kayak, paddle_check: paddleBench({ data: 'none', open: 3 }) }} />);
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
});
it('PCD-002b: data that could not be read still shows, without a count', () => {
render(<TourDayRowPills tour={{ ...kayak, paddle_check: paddleBench({ data: 'unavailable', open: 3 }) }} />);
expect(screen.getByTestId('plan-tour-paddle-check')).not.toHaveTextContent('3');
});
it.each([false, true])('PCD-003: the day plan facts name the bench as one more fact (mobile=%s)', (mobile) => {
render(<TourDayRowFacts tour={kayak} mobile={mobile} />);
expect(screen.getByTestId('plan-tour-paddle-check')).toHaveAccessibleName('Checklist: 1 open');
});
const without: Array<[string, TourListItem]> = [
['a hike with a bench', { ...tour, paddle_check: paddleBench() }],
['a kayak tour without a bench', { ...tour, tour_type: 'kayak' }],
['a kayak tour whose bench is null', { ...tour, tour_type: 'kayak', paddle_check: null }],
];
it.each(without)('PCD-004: %s shows no checklist pill and no checklist fact', (_case, item) => {
render(
<>
<TourDayRowPills tour={item} />
<TourDayRowFacts tour={item} />
</>
);
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
});
});
describe('the compact facts of a tour list row', () => {
const testIds = () =>
[...document.querySelectorAll('[data-testid^="plan-tour"], [data-testid="trip-plan-tour-time"]')].map((el) =>
el.getAttribute('data-testid')
);
const timed = { ...tour, distance: 4.2, elevation_gain: 478, planned_duration_minutes: 50 };
it('PCD-005: a hike keeps time, distance and climbing, in that order', () => {
render(<TourDayRowPills tour={timed} compact />);
expect(testIds()).toEqual(['trip-plan-tour-time', 'plan-tour-distance', 'plan-tour-ascent']);
});
it('PCD-006: a paddle tour puts its check bench second and leaves out the climbing', () => {
render(<TourDayRowPills tour={{ ...timed, tour_type: 'kayak', paddle_check: paddleBench() }} compact />);
expect(testIds()).toEqual(['trip-plan-tour-time', 'plan-tour-paddle-check', 'plan-tour-distance']);
});
it('PCD-007: the day plan still shows every fact', () => {
render(<TourDayRowPills tour={{ ...timed, tour_type: 'kayak', paddle_check: paddleBench() }} />);
expect(testIds()).toEqual([
'trip-plan-tour-time',
'plan-tour-distance',
'plan-tour-ascent',
'plan-tour-paddle-check',
]);
});
});
+55 -34
View File
@@ -1,5 +1,5 @@
import type { TourListItem } from '@trek/shared';
import { ArrowUp, Clock, Footprints, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { ArrowUp, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { createElement, useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
@@ -7,7 +7,9 @@ import { formatDistance, formatElevation } from '../../utils/units';
import { WhiteBadge } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { paddleCheckFact } from './paddle/paddleCheckModel';
import { tourDayRowIcon } from './tourDayRowIcon';
import { movingTimeLabel } from './tourFacts';
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
@@ -117,7 +119,7 @@ export function TourDayRowAvatar({
}
/** One fact of a tour as the white badge a place's time sits in, named by its tooltip. */
function TourDayRowPill({
export function TourDayRowPill({
icon: Icon,
value,
label,
@@ -139,19 +141,32 @@ function TourDayRowPill({
);
}
/**
* A paddle tour's check bench as one fact (PAD-09), or null for a hike, a tour
* listed without one, or an area without obstacle data: a row has no room for
* a badge that says nothing, and the checklist itself still names the gap.
*/
function paddleCheckOf(tour: TourListItem, t: Parameters<typeof paddleCheckFact>[1]) {
const check = tour.tour_type === 'kayak' ? tour.paddle_check : null;
return check && check.data !== 'none' ? paddleCheckFact(check, t) : null;
}
/**
* A tour's facts on its day-plan row, in the badges every other stop uses: the
* time it takes (the planned total where there is one, otherwise the walking
* time, the parts in the tooltip), how far and how much climbing.
* time, the parts in the tooltip), how far, how much climbing and, for a paddle
* tour, its check bench. `compact` (the tour list) keeps the three that matter
* most, in that order: the time, then a paddle tour's check bench and its
* distance, or a hike's distance and climbing.
*/
export function TourDayRowPills({ tour }: { tour: TourListItem }) {
export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem; compact?: boolean }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
const times = tourPlannedTimes(tour);
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes;
const timeParts = [
times.walkingMinutes != null
? `${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(times.walkingMinutes)}`
? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}`
: null,
times.breakMinutes
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
@@ -160,34 +175,38 @@ export function TourDayRowPills({ tour }: { tour: TourListItem }) {
? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
: null,
].filter((part): part is string => part !== null);
return (
<>
{shownMinutes != null && (
<TourDayRowPill
icon={Clock}
value={formatPlannedTourDuration(shownMinutes)}
label={timeParts.join(', ')}
testId="trip-plan-tour-time"
/>
)}
{tour.distance != null && (
<TourDayRowPill
icon={Ruler}
value={formatDistance(tour.distance, unit)}
label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`}
testId="plan-tour-distance"
/>
)}
{tour.elevation_gain != null && (
<TourDayRowPill
icon={ArrowUp}
value={formatElevation(tour.elevation_gain, unit)}
label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}
testId="plan-tour-ascent"
/>
)}
</>
const check = paddleCheckOf(tour, t);
const time = shownMinutes != null && (
<TourDayRowPill
key="time"
icon={Clock}
value={formatPlannedTourDuration(shownMinutes)}
label={timeParts.join(', ')}
testId="trip-plan-tour-time"
/>
);
const distance = tour.distance != null && (
<TourDayRowPill
key="distance"
icon={Ruler}
value={formatDistance(tour.distance, unit)}
label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`}
testId="plan-tour-distance"
/>
);
const ascent = tour.elevation_gain != null && (
<TourDayRowPill
key="ascent"
icon={ArrowUp}
value={formatElevation(tour.elevation_gain, unit)}
label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}
testId="plan-tour-ascent"
/>
);
const bench = check && <TourDayRowPill key="check" {...check} testId="plan-tour-paddle-check" />;
if (!compact) return <>{[time, distance, ascent, bench]}</>;
// A paddle tour's climbing is the river's fall, nothing to plan by; its check bench is.
return <>{tour.tour_type === 'kayak' ? [time, bench, distance] : [time, distance, ascent]}</>;
}
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
@@ -203,9 +222,9 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
if (times.walkingMinutes != null)
add(
Footprints,
tourDayRowIcon(tour.tour_type === 'kayak' ? 'kayak' : 'walk'),
formatPlannedTourDuration(times.walkingMinutes),
t('tours.planner.inspector.duration'),
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
'trip-plan-tour-walking-time'
);
if (times.breakMinutes != null && times.breakMinutes > 0)
@@ -222,6 +241,8 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
`${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
'trip-plan-tour-planned-duration'
);
const check = paddleCheckOf(tour, t);
if (check) add(check.icon, check.value, t('tours.paddle.check.title'), 'plan-tour-paddle-check');
return (
<span data-testid="plan-tour-facts" className="flex min-w-0 flex-wrap items-center gap-1">
{facts.map((fact) => (
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/rende
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { buildPlace } from '../../../tests/helpers/factories'
import { paddleBench } from '../../../tests/helpers/paddleCheck'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import TourDetailDialog from './TourDetailDialog'
@@ -141,6 +142,16 @@ describe('TourDetailDialog', () => {
expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument()
})
it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', has_waypoints: true, duration: 50 }} />)
const row = screen.getByText('Distance').closest('dl')
expect(row).toHaveClass('grid-cols-3')
expect(row).toContainElement(screen.getByText('Paddling'))
expect(row).toContainElement(screen.getByText('Planned'))
expect(screen.queryByText('Ascent')).not.toBeInTheDocument()
})
it('RS-01: rejects an unsafe legacy Tour website for rendering', () => {
render(
<TourDetailDialog
@@ -214,4 +225,19 @@ describe('TourDetailDialog', () => {
expect(screen.getAllByText('T1').length).toBeGreaterThan(0)
expect(screen.queryByText(/easy/i)).not.toBeInTheDocument()
})
it('PCT-001: a paddle tour shows its checklist and the safety row before the wind, a hike neither', () => {
const { unmount } = render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', paddle_check: paddleBench() }} />)
expect(screen.getByText('Checklist')).toBeInTheDocument()
expect(screen.getByTestId('paddle-check-summary')).toBeInTheDocument()
const safety = screen.getByRole('button', { name: /Paddling has risks/ })
expect(safety.compareDocumentPosition(screen.getByTestId('paddle-wind-card'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
unmount()
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, paddle_check: paddleBench() }} />)
expect(screen.queryByText('Checklist')).not.toBeInTheDocument()
expect(screen.queryByTestId('paddle-check-summary')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Paddling has risks/ })).not.toBeInTheDocument()
})
})
@@ -15,7 +15,6 @@ import {
FileText,
Loader2,
Minus,
Mountain,
Navigation,
Pencil,
Plus,
@@ -23,14 +22,14 @@ import {
Ruler,
Upload,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
import Markdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import { translateApiError, useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import type { Assignment, AssignmentsMap, Day, Place, TripFile } from '../../types';
import type { Assignment, AssignmentsMap, Day, DistanceUnit, Place, TripFile } from '../../types';
import { openFile } from '../../utils/fileDownload';
import { filesForPlace } from '../../utils/placeFiles';
import { analyzeRouteGeometry } from '../../utils/routeGeometry';
@@ -48,8 +47,12 @@ import { Tooltip } from '../shared/Tooltip';
import TrackColorPicker from '../shared/TrackColorPicker';
import { markdownLinkComponents } from '../shared/markdownLink';
import { TourDayRowAvatar } from './TourDayRowFacts';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
import { tourDayRowIcon } from './tourDayRowIcon';
import { tourDaysOf } from './tourDays';
import { heightsUnknown, movingTimeLabel, withoutHeights } from './tourFacts';
import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField } from './tourParts';
import {
formatPlannedTourDuration,
@@ -113,13 +116,67 @@ function assignmentForSelectedDay(
);
}
/** The tour's measures, its times and its profile. Without heights the distance joins the times in one row. */
function TourDetailMeasures({
analysis,
unit,
timeFields,
trackColor,
placeId,
}: {
analysis: NonNullable<ReturnType<typeof analyzeRouteGeometry>>;
unit: DistanceUnit;
timeFields: TourMetricField[];
trackColor: string;
placeId: number;
}) {
return (
<div className="flex flex-none flex-col gap-2">
{analysis.minEle == null && analysis.gain == null ? (
<TourMetricFields
analysis={analysis}
unit={unit}
extra={timeFields}
columns={timeFields.length === 1 ? 2 : timeFields.length === 2 ? 3 : undefined}
/>
) : (
<>
<TourMetricFields analysis={analysis} unit={unit} columns={5} />
{timeFields.length > 0 && <TourFactFields fields={timeFields} columns={3} />}
</>
)}
{analysis.distanceIndexedProfileSamples.length >= 2 && (
<div className={`${BOX} p-2`}>
<ElevationProfile
samples={analysis.distanceIndexedProfileSamples}
color={trackColor}
gradientId={`tour-elevation-${placeId}`}
/>
</div>
)}
</div>
);
}
/** Where a tour starts, the place its wind is asked for (PAD-08). */
function placeStart(place: Place): { lat: number; lng: number } | null {
return place.lat != null && place.lng != null ? { lat: place.lat, lng: place.lng } : null;
}
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */
function tourTimeFields(times: ReturnType<typeof tourPlannedTimes>, t: Translate): TourMetricField[] {
function tourTimeFields(
times: ReturnType<typeof tourPlannedTimes>,
t: Translate,
tour: Pick<TourListItem, 'tour_type'>
): TourMetricField[] {
return [
times.walkingMinutes != null
? { label: t('tours.planner.walkingShort'), value: formatPlannedTourDuration(times.walkingMinutes) }
? {
label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t),
value: formatPlannedTourDuration(times.walkingMinutes),
}
: null,
times.breakMinutes
? { label: t('tours.planner.breaksShort'), value: formatPlannedTourDuration(times.breakMinutes) }
@@ -166,9 +223,25 @@ function TourHeadPills({
return (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<span className={HEAD_PILL} style={fs(10.5)}>
<Mountain size={11} strokeWidth={2} className="flex-none" style={{ color: trackColor }} />
{createElement(tourDayRowIcon(tour.tour_type), {
size: 11,
strokeWidth: 2,
className: 'flex-none',
style: { color: trackColor },
})}
{t(`tourTypes.${tour.tour_type}`)}
</span>
{/* A paddle tour names its boat and its water, each a badge of its own. */}
{tour.boat_type && (
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-boat">
{t(`tours.paddle.boat.${tour.boat_type}`)}
</span>
)}
{tour.water_context && (
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-water">
{t(`tours.paddle.water.${tour.water_context}`)}
</span>
)}
{source !== 'trek' && (
<span className={HEAD_PILL} style={fs(10.5)}>
<FileDown size={11} strokeWidth={2} className="flex-none text-content-faint" />
@@ -337,7 +410,11 @@ export default function TourDetailDialog({
);
useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget);
const analysis = useMemo(() => analyzeRouteGeometry(place.route_geometry), [place.route_geometry]);
const heightsHidden = heightsUnknown(tour);
const analysis = useMemo(() => {
const measured = analyzeRouteGeometry(place.route_geometry);
return measured && heightsHidden ? withoutHeights(measured) : measured;
}, [heightsHidden, place.route_geometry]);
const trackColor = resolveTrackColor(place);
const inheritedColor = inheritedTrackColor(place);
const assignment = assignmentForSelectedDay(assignments, selectedDayId, selectedAssignmentId, place.id);
@@ -350,7 +427,7 @@ export default function TourDetailDialog({
const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website);
const times = tourPlannedTimes(tour);
const timeFields = tourTimeFields(times, t);
const timeFields = tourTimeFields(times, t, tour);
const header = (
<>
@@ -562,20 +639,16 @@ export default function TourDetailDialog({
</DialogSection>
{analysis && (
<div className="flex flex-none flex-col gap-2">
<TourMetricFields analysis={analysis} unit={distanceUnit} columns={5} />
{timeFields.length > 0 && <TourFactFields fields={timeFields} columns={3} />}
{analysis.distanceIndexedProfileSamples.length >= 2 && (
<div className={`${BOX} p-2`}>
<ElevationProfile
samples={analysis.distanceIndexedProfileSamples}
color={trackColor}
gradientId={`tour-elevation-${place.id}`}
/>
</div>
)}
</div>
<TourDetailMeasures
analysis={analysis}
unit={distanceUnit}
timeFields={timeFields}
trackColor={trackColor}
placeId={place.id}
/>
)}
{tour.tour_type === 'kayak' && <PaddleCheckSection tour={tour} />}
{tour.tour_type === 'kayak' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
{description && (
<DialogSection className="flex-none" label={t('places.formDescription')}>
@@ -6,7 +6,15 @@ import TourGpxDropZone from './TourGpxDropZone';
import type { TourGpxImport } from './useTourGpxImport';
function gpxImport(overrides: Partial<TourGpxImport> = {}): TourGpxImport {
return { importing: false, importFiles: vi.fn().mockResolvedValue(undefined), pick: vi.fn(), ...overrides };
return {
importing: false,
importFiles: vi.fn().mockResolvedValue(undefined),
pick: vi.fn(),
preview: null,
confirm: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn(),
...overrides,
};
}
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' });
@@ -2,6 +2,7 @@ import { Ban, FileDown, Loader2 } from 'lucide-react';
import { useRef, useState, type DragEvent, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
import type { TourGpxImport } from './useTourGpxImport';
/** Only a drag of files from the desktop; a waypoint or a tour row being dragged is not one. */
@@ -64,6 +65,7 @@ export default function TourGpxDropZone({
}}
>
{children}
<TourGpxPreviewDialog gpxImport={gpxImport} />
{(over || gpxImport.importing) && (
<div
data-testid="tour-gpx-drop"
@@ -0,0 +1,216 @@
import type { PaddleBoatType, PaddleWaterContext, PlannableTourType, TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, FileDown, Info, Mountain, Waves } from 'lucide-react';
import { useId, useMemo, useState } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { MapViewAuto } from '../Map/MapViewAuto';
import { SoftPill } from '../Planner/planParts';
import { LABEL, Segmented } from '../shared/dialogParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TourNotice } from './tourParts';
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
const BADGE =
'inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 font-geist tabular-nums leading-[20px] text-content-secondary';
/** One track of the file as a row: its name, and what it measures as badges; a track the trip holds says so. */
function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile['tracks'][number] }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const heights = track.elevation_gain_m != null && track.elevation_loss_m != null;
return (
<li
data-testid="gpx-preview-track"
data-duplicate={track.duplicate || undefined}
className={`flex min-w-0 flex-col gap-1.5 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 ${track.duplicate ? 'opacity-60' : ''}`}
>
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
{track.name || name}
</span>
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
</div>
<div className="flex flex-wrap items-center gap-1.5" style={fs(11)}>
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
{heights ? (
<>
<span className={BADGE}>↑ {formatElevation(track.elevation_gain_m ?? 0, unit)}</span>
<span className={BADGE}>↓ {formatElevation(track.elevation_loss_m ?? 0, unit)}</span>
</>
) : (
<span className={BADGE}>{t('tours.import.previewNoHeights')}</span>
)}
<span className={BADGE}>{t('tours.import.previewPoints', { count: track.point_count })}</span>
</div>
</li>
);
}
/**
* PAD-02: a GPX file shown before anything is stored. The whole course on a
* map, every track with its measures, the tracks the trip already holds named
* as skipped, and the type to import them as; a paddle import names its boat
* and water, as a planned paddle tour does. Closing it stores nothing.
*/
export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpxImport }) {
const { preview } = gpxImport;
if (!preview) return null;
return <PreviewDialog gpxImport={gpxImport} files={preview} />;
}
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
const { t } = useTranslation();
const labelId = useId();
const tileUrl = useTileUrl(OFM_POSITRON);
const [type, setType] = useState<PlannableTourType>('hike');
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
const [water, setWater] = useState<PaddleWaterContext | null>(null);
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
const lines = useMemo(
() =>
files.flatMap((file) =>
file.tracks.map((track) => track.geometry.map(([lat, lng]): [number, number] => [lat, lng]))
),
[files]
);
const focusPoints = useMemo(() => lines.flat(), [lines]);
const paddleIncomplete = type === 'kayak' && (boat === null || water === null);
const canImport = fresh > 0 && !paddleIncomplete && !gpxImport.importing;
const fields: TourGpxImportFields =
type === 'kayak' && boat && water
? { tour_type: 'kayak', boat_type: boat, water_context: water }
: { tour_type: 'hike' };
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
return (
<DialogShell
onClose={gpxImport.cancel}
labelledBy={labelId}
width="editor"
blocked={gpxImport.importing}
header={
<DialogHeader
tile={
<DialogTile>
<FileDown size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={gpxImport.cancel}
eyebrow={t('tours.import.previewTitle')}
title={title}
sub={t('tours.import.previewHint')}
subWraps
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={gpxImport.cancel} disabled={gpxImport.importing}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="gpx-preview-import"
icon={<FileDown size={14} strokeWidth={2} />}
disabled={!canImport}
onClick={() => void gpxImport.confirm(fields)}
>
{t('tours.import.previewConfirm', { count: fresh })}
</DialogButton>
</DialogFooter>
}
>
<div
data-testid="gpx-preview-map"
className="relative h-[280px] flex-none overflow-hidden rounded-[14px] border border-edge-faint"
>
<MapViewAuto
places={[]}
route={lines}
tileUrl={tileUrl}
focusPoints={focusPoints}
focusKey={files.map(({ file }) => file.name).join('|')}
followSelection={false}
/>
</div>
<DialogSection label={t('tours.import.previewTracks', { count: tracks.length })}>
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
{tracks.map(({ file, track }, index) => (
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} track={track} />
))}
</ul>
{fresh === 0 && (
<div className="mt-2">
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.import.previewAllDuplicates')}
</TourNotice>
</div>
)}
</DialogSection>
<DialogSection label={t('tours.import.previewAs')}>
<div className="flex flex-col gap-3">
<Segmented<PlannableTourType>
value={type}
onChange={setType}
label={t('tours.import.previewAs')}
fill
options={[
{ value: 'hike', label: t('tourTypes.hike'), icon: <Mountain size={13} strokeWidth={2} /> },
{ value: 'kayak', label: t('tourTypes.kayak'), icon: <Waves size={13} strokeWidth={2} /> },
]}
/>
{type === 'kayak' && (
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.boat')}</span>
<Segmented<PaddleBoatType>
value={(boat ?? '') as PaddleBoatType}
onChange={setBoat}
label={t('tours.paddle.boat')}
fill
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
/>
</div>
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.water')}</span>
<Segmented<PaddleWaterContext>
value={(water ?? '') as PaddleWaterContext}
onChange={setWater}
label={t('tours.paddle.water')}
fill
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
/>
</div>
</div>
)}
{paddleIncomplete && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t('tours.paddle.chooseBoatImport')}
</TourNotice>
)}
</div>
</DialogSection>
</DialogShell>
);
}
+17 -17
View File
@@ -47,7 +47,7 @@ function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['map
)
}
/** The tile, the name and one line of badges with the description trailing after them. */
/** The tile, the name, the description on a line of its own and one line of badges. */
function TourRowBody({ tour, nameId, tourDays, dimmed }: {
tour: TourListItem
nameId: string
@@ -60,24 +60,24 @@ function TourRowBody({ tour, nameId, tourDays, dimmed }: {
<div className="min-w-0 flex-1">
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content"
style={fs(13, 'body')}>{tour.name}</span>
{/* One line like a plan row's: the badges keep their place, the description gives way,
and what does not fit fades out instead of ending at a hard edge. */}
{tour.description && (
<span
data-testid="tour-description-preview"
className="mt-0.5 block min-w-0 truncate text-content-faint"
style={fs(10.5)}
>
{tour.description}
</span>
)}
{/* One line of whole badges, most important first: one that does not fit wraps onto a
second line the fixed height hides, so a narrow sidebar shows two and never half of one. */}
<div
data-testid="tour-metrics"
className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden"
className="-mx-0.5 mt-0.5 flex h-6 min-w-0 flex-wrap content-start gap-x-1.5 gap-y-2 overflow-hidden px-0.5 pt-0.5"
>
<TourCautionBadge tour={tour} />
<TourDayRowPills tour={tour} />
<TourDayRowPills tour={tour} compact />
<TourDaysBadge tourDays={tourDays} />
{tour.description && (
<span
data-testid="tour-description-preview"
className="min-w-0 flex-1 truncate text-content-faint"
style={fs(10.5)}
>
{tour.description}
</span>
)}
</div>
</div>
</div>
@@ -108,9 +108,9 @@ function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps,
/**
* A tour in a list, laid out like a stop in the day plan: its tile with the
* grade, the name, and one line of the plan's white badges (time, distance,
* ascent, the days it is on, a caution) with the description trailing after
* them. The day and delete actions sit on the right.
* grade, the name, the description below it, and one line of the plan's
* white badges (time, distance, ascent, the days it is on, a caution). The day
* and delete actions sit on the right.
*/
export default function TourListRow({ tour,
days = NO_DAYS,
@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import TourWorkspaceRail from './TourWorkspaceRail';
import TourWorkspaceRail, { type TourWorkspaceRailParts } from './TourWorkspaceRail';
import type { GpxConversion } from './conversion/useGpxConversion';
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
import type { TourPlannerController } from './planner/useTourPlanner';
import type { RecordedTrack } from './useRecordedTrack';
import type { WandererBrowser } from './wanderer/useWandererBrowser';
@@ -23,18 +25,13 @@ function wanderer(overrides: Partial<WandererBrowser>): WandererBrowser {
return { open: false, enabled: true, openBrowser: vi.fn(), ...overrides } as unknown as WandererBrowser;
}
function workspace(browser: WandererBrowser): TourWorkspaceRailParts {
return { tourPlanner: planner, wandererBrowser: browser, tourConversion: conversion, recordedTrack };
}
describe('TourWorkspaceRail', () => {
it('RS-04: shows the wanderer trails in place of the editor while they are browsed', () => {
render(
<TourWorkspaceRail
planner={planner}
wanderer={wanderer({ open: true })}
conversion={conversion}
recordedTrack={recordedTrack}
canEdit
canAssign
/>
);
render(<TourWorkspaceRail workspace={workspace(wanderer({ open: true }))} canEdit canAssign />);
expect(screen.getByText('wanderer trails')).toBeInTheDocument();
expect(screen.queryByText('tour editor')).not.toBeInTheDocument();
});
@@ -42,16 +39,7 @@ describe('TourWorkspaceRail', () => {
it('RS-04: offers the wanderer trails from the editor only while wanderer may be used', () => {
const browser = wanderer({});
seen.rail.length = 0;
const view = render(
<TourWorkspaceRail
planner={planner}
wanderer={browser}
conversion={conversion}
recordedTrack={recordedTrack}
canEdit
canAssign={false}
/>
);
const view = render(<TourWorkspaceRail workspace={workspace(browser)} canEdit canAssign={false} />);
expect(screen.getByText('tour editor')).toBeInTheDocument();
expect(seen.rail[seen.rail.length - 1]).toMatchObject({
planner,
@@ -62,16 +50,17 @@ describe('TourWorkspaceRail', () => {
onOpenWanderer: browser.openBrowser,
});
view.rerender(
<TourWorkspaceRail
planner={planner}
wanderer={wanderer({ enabled: false })}
conversion={conversion}
recordedTrack={recordedTrack}
canEdit
canAssign
/>
);
view.rerender(<TourWorkspaceRail workspace={workspace(wanderer({ enabled: false }))} canEdit canAssign />);
expect(seen.rail[seen.rail.length - 1]?.onOpenWanderer).toBeUndefined();
});
it('PCW-001: hands the paddle plan and the checklist of the tour in view on to the editor rail', () => {
const trackPaddlePlan = { pick: null } as unknown as TrackPaddlePlan;
const paddleChecklist = { status: 'ready' } as unknown as PaddleChecklist;
const parts = { ...workspace(wanderer({})), trackPaddlePlan, paddleChecklist };
seen.rail.length = 0;
render(<TourWorkspaceRail workspace={parts} canEdit canAssign />);
expect(seen.rail[seen.rail.length - 1]?.trackPaddlePlan).toBe(trackPaddlePlan);
expect(seen.rail[seen.rail.length - 1]?.paddleChecklist).toBe(paddleChecklist);
});
});
@@ -1,35 +1,53 @@
import type { GpxConversion } from './conversion/useGpxConversion';
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
import { TourPlannerRail } from './planner/TourPlannerPanels';
import type { TourPlannerController } from './planner/useTourPlanner';
import type { RecordedTrack } from './useRecordedTrack';
import WandererBrowserRail from './wanderer/WandererBrowserRail';
import type { WandererBrowser } from './wanderer/useWandererBrowser';
/** What the tours workspace hands its left rail: the parts of the planner page's state, by their names there. */
export interface TourWorkspaceRailParts {
tourPlanner: TourPlannerController;
wandererBrowser: WandererBrowser;
tourConversion: GpxConversion;
recordedTrack: RecordedTrack;
/** The portages and locks of a recorded paddle track in view. */
trackPaddlePlan?: TrackPaddlePlan;
/** The obstacle checklist of the paddle tour open in the planner (PAD-06). */
paddleChecklist?: PaddleChecklist;
}
/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */
export default function TourWorkspaceRail({
planner,
wanderer,
conversion,
recordedTrack,
workspace,
canEdit,
canAssign,
}: {
planner: TourPlannerController;
wanderer: WandererBrowser;
conversion: GpxConversion;
recordedTrack: RecordedTrack;
workspace: TourWorkspaceRailParts;
canEdit: boolean;
canAssign: boolean;
}) {
const {
tourPlanner,
wandererBrowser: wanderer,
tourConversion,
recordedTrack,
trackPaddlePlan,
paddleChecklist,
} = workspace;
return wanderer.open ? (
<WandererBrowserRail browser={wanderer} />
) : (
<TourPlannerRail
planner={planner}
planner={tourPlanner}
canEdit={canEdit}
canAssign={canAssign}
conversion={conversion}
conversion={tourConversion}
recordedTrack={recordedTrack}
trackPaddlePlan={trackPaddlePlan}
paddleChecklist={paddleChecklist}
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
/>
);
@@ -6,12 +6,13 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
import { useTripStore } from '../../store/tripStore'
import { toursApi } from '../../api/client'
import { tourGpxApi } from '../../api/tourGpx'
import ToursSidebar from './ToursSidebar'
import { pickFiles } from '../../../tests/helpers/filePicker'
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }))
vi.mock('../Map/MapViewAuto', () => ({ MapViewAuto: () => <div data-testid="gpx-preview-map-stub" /> }))
vi.mock('../shared/Toast', () => ({ useToast: () => toast }))
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
@@ -41,6 +42,19 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
})
function previewTrack(overrides: Record<string, unknown> = {}) {
return {
name: 'Ridge',
geometry: [[47, 11, 600], [47.01, 11.01, 650], [47.02, 11.02, 640]] as [number, number, number][],
point_count: 3,
distance_km: 2.4,
elevation_gain_m: 50,
elevation_loss_m: 10,
duplicate: false,
...overrides,
}
}
describe('ToursSidebar', () => {
it('uses the compact counted dropdown for All, Unplanned and Planned', () => {
render(<ToursSidebar tripId={1} days={[]} tours={[tour, { ...tour, place_id: 43, name: 'Planned walk', planned: true }]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
@@ -61,15 +75,18 @@ describe('ToursSidebar', () => {
expect(row).toHaveClass('bg-surface-selected')
})
it('RS-01: trails the description after the badges on one line, as a plan row does', () => {
it('RS-01: puts the description on its own line between the name and the badges', () => {
const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 }
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
const line = within(row).getByTestId('tour-metrics')
expect(line).toHaveClass('flex-nowrap', 'overflow-hidden')
expect(line).toHaveClass('flex-wrap', 'h-6', 'overflow-hidden')
expect(line).toHaveTextContent('1 h 35 min')
expect(within(line).getByTestId('tour-description-preview')).toHaveTextContent('A quiet path above the lake')
expect(within(line).getByTestId('tour-description-preview')).toHaveClass('truncate')
const description = within(row).getByTestId('tour-description-preview')
expect(line).not.toContainElement(description)
expect(description).toHaveTextContent('A quiet path above the lake')
expect(description).toHaveClass('truncate')
expect(description.compareDocumentPosition(line) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// The information link belongs to the detail, not the list row.
expect(row).not.toHaveTextContent('Komoot')
})
@@ -205,7 +222,8 @@ describe('ToursSidebar', () => {
})
it('quarantines returned Tour ids before loading the imported Places', async () => {
const importCall = vi.spyOn(toursApi, 'importGpx').mockResolvedValue({
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({
tours: [tour], caution: false, skipped: 0,
})
const onToursChanged = vi.fn()
@@ -224,6 +242,7 @@ describe('ToursSidebar', () => {
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx')
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
// Only the places are read again, not the whole trip: that would rebuild the workspace.
await waitFor(() => expect(refreshPlaces).toHaveBeenCalledWith(1))
@@ -231,11 +250,13 @@ describe('ToursSidebar', () => {
expect(onToursChanged).toHaveBeenCalledWith([42])
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(refreshPlaces.mock.invocationCallOrder[0])
expect(importCall).toHaveBeenCalledOnce()
expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' })
vi.restoreAllMocks()
})
it('reports duplicate-only GPX success without refetching or invalidating unchanged data', async () => {
vi.spyOn(toursApi, 'importGpx').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
const onToursChanged = vi.fn()
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
render(<ToursSidebar
@@ -250,6 +271,7 @@ describe('ToursSidebar', () => {
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
])
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('Everything in that file is already in this trip.'))
expect(onToursChanged).not.toHaveBeenCalled()
@@ -257,9 +279,10 @@ describe('ToursSidebar', () => {
vi.restoreAllMocks()
})
it('RC-04: names each file that fails and still imports the rest', async () => {
vi.spyOn(toursApi, 'importGpx')
vi.spyOn(tourGpxApi, 'preview')
.mockRejectedValueOnce({ response: { data: { error: 'No track or route found in GPX file' } } })
.mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 })
.mockResolvedValueOnce({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
@@ -269,15 +292,78 @@ describe('ToursSidebar', () => {
new File(['<gpx/>'], 'empty.gpx', { type: 'application/gpx+xml' }),
new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' }),
])
// The file without a track is named at once and left out of the preview.
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx'))
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported from GPX'))
expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx')
expect(importCall).toHaveBeenCalledOnce()
expect(toast.info).toHaveBeenCalledWith('Imported with caution — elevation/metrics incomplete')
vi.restoreAllMocks()
})
it('PAD-02: shows the file before storing anything, and closing the preview stores nothing', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack(), previewTrack({ name: 'Back', duplicate: true })] })
const importCall = vi.spyOn(tourGpxApi, 'import')
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveTextContent('GPX preview')
expect(dialog).toHaveTextContent('ridge.gpx')
expect(screen.getAllByTestId('gpx-preview-track')).toHaveLength(2)
expect(screen.getAllByTestId('gpx-preview-track')[1]).toHaveTextContent('Already in the trip')
expect(screen.getAllByTestId('gpx-preview-track')[0]).toHaveTextContent('3 points')
// Only the track the trip does not hold yet is imported.
expect(screen.getByRole('button', { name: 'Import 1 tour' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
expect(importCall).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('PAD-02: a file the trip holds completely cannot be imported again, and says why', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack({ duplicate: true })] })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
expect(await screen.findByText('Every track is already in the trip, so there is nothing to import.')).toBeInTheDocument()
expect(screen.getByTestId('gpx-preview-import')).toBeDisabled()
vi.restoreAllMocks()
})
it('PAD-02: a paddle import waits for its boat and water, then sends them along', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack({ elevation_gain_m: null, elevation_loss_m: null })] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<gpx/>'], 'lake.gpx', { type: 'application/gpx+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
await screen.findByRole('dialog')
// A file without heights says so instead of showing zero.
expect(screen.getByTestId('gpx-preview-track')).toHaveTextContent('No heights in the file')
fireEvent.click(screen.getByRole('button', { name: 'Paddle tour' }))
const confirm = screen.getByTestId('gpx-preview-import')
expect(confirm).toBeDisabled()
expect(screen.getByText('Choose the boat and the water to import as a paddle tour.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'SUP' }))
fireEvent.click(screen.getByRole('button', { name: 'Lake' }))
expect(confirm).toBeEnabled()
fireEvent.click(confirm)
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' }))
vi.restoreAllMocks()
})
it('RC-04: refuses files that are not GPX before uploading anything', async () => {
const importCall = vi.spyOn(toursApi, 'importGpx')
const importCall = vi.spyOn(tourGpxApi, 'import')
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
@@ -11,6 +11,7 @@ import EmptyState from '../shared/EmptyState'
import { TourDayMenu } from './tourParts'
import TourListFilterMenu from './TourListFilterMenu'
import { useTourListFilters } from './useTourListFilters'
import TourGpxPreviewDialog from './TourGpxPreviewDialog'
import { useTourGpxImport } from './useTourGpxImport'
import { type AssignTourToDay, useTourDayAssignment } from './useTourDayAssignment'
import { useTourMapVisibility } from './useTourMapVisibility'
@@ -130,6 +131,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
</ul>
)}
</div>
<TourGpxPreviewDialog gpxImport={gpxImport} />
</div>
)
}
@@ -77,7 +77,7 @@ export default function TourConversionSection({ conversion }: { conversion: GpxC
return card(
<div className="flex flex-col items-start gap-2.5">
<p className="m-0 leading-snug text-content-secondary" style={fs(12, 'body')}>
{t('tours.conversion.intro')}
{t(conversion.paddle ? 'tours.conversion.introPaddle' : 'tours.conversion.intro')}
</p>
<CardAction onClick={() => void conversion.analyze()} icon={<Route size={12} strokeWidth={2.2} />}>
{t('tours.conversion.analyze')}
@@ -138,7 +138,13 @@ export default function TourConversionSection({ conversion }: { conversion: GpxC
)
}
>
{t(faithful ? 'tours.conversion.close' : 'tours.conversion.different')}
{t(
faithful
? conversion.paddle
? 'tours.conversion.closePaddle'
: 'tours.conversion.close'
: 'tours.conversion.different'
)}
</StatusLine>
<div className="flex flex-col gap-2 border-t border-edge-faint pt-3">
<Tooltip label={t('tours.conversion.replaceHint')}>
@@ -1,6 +1,8 @@
import type { TourMaxHikingDifficulty } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { freeLegBetween } from '../planner/tourRouteActions';
import { routeWalkingTour } from '../planner/tourRouting';
import { paddleFieldsOf } from '../planner/usePaddleFields';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
@@ -37,6 +39,8 @@ export function useGpxConversion(planner: TourPlannerController) {
const track = planner.readOnlyGpxAnalysis?.routeCoordinates ?? null;
const current = tour !== null && state.placeId === tour.place_id ? state : IDLE;
const difficulty = (tour?.max_hiking_difficulty ?? DEFAULT_DIFFICULTY) as TourMaxHikingDifficulty;
// A paddle import is planned as a paddle tour is: every leg drawn by hand, no router asked.
const paddle = tour?.tour_type === 'kayak';
useEffect(() => () => abort.current?.abort(), []);
@@ -53,7 +57,13 @@ export function useGpxConversion(planner: TourPlannerController) {
return;
}
setState({ ...base, status: 'analyzing' });
const routed = await routeWalkingTour(points, controller.signal, difficulty);
const legs = paddle
? points.map((point, index) => ({
...point,
fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null,
}))
: points;
const routed = await routeWalkingTour(legs, controller.signal, difficulty);
if (controller.signal.aborted) return;
if (!routed) {
setState({ ...base, status: 'failed', error: 'routing' });
@@ -66,7 +76,7 @@ export function useGpxConversion(planner: TourPlannerController) {
line: routed.coordinates,
comparison: compareRoutes(trackPoints, suggestion),
});
}, [difficulty, tour, track]);
}, [difficulty, paddle, tour, track]);
const cancel = useCallback(() => {
abort.current?.abort();
@@ -89,11 +99,12 @@ export function useGpxConversion(planner: TourPlannerController) {
points: current.points,
sourcePlaceId: tour.place_id,
inPlace: how === 'replace',
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
});
if (started) setState(IDLE);
return started;
},
[current.points, current.status, difficulty, startDerivedTour, tour]
[current.points, current.status, difficulty, paddle, startDerivedTour, tour]
);
return useMemo(
@@ -102,11 +113,13 @@ export function useGpxConversion(planner: TourPlannerController) {
/** The import shown can be planned from: it has a track and the user may add tours. */
available: tour !== null && (track?.length ?? 0) >= 2 && planner.canEdit,
difficulty,
/** The import is a paddle tour: its version is drawn with straight legs, not routed. */
paddle,
analyze,
cancel,
adopt,
}),
[adopt, analyze, cancel, current, difficulty, planner.canEdit, tour, track]
[adopt, analyze, cancel, current, difficulty, paddle, planner.canEdit, tour, track]
);
}
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
/**
* A figure as a badge holding its value as a white badge, the planner's
* badge-in-badge look: the wind of the day, a weir's height, an access rule.
* What it means exactly can sit in its tooltip.
*/
export function FigureBadge({
label,
value,
icon,
info,
tone = 'neutral',
testId,
}: {
label?: string;
value: string;
icon?: ReactNode;
info?: string;
/** `warning` colours the value: a rule that forbids, a way recorded as private. */
tone?: 'neutral' | 'warning';
testId?: string;
}) {
const badge = (
<span
data-testid={testId}
className="inline-flex h-[24px] min-w-0 flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-secondary pe-[3px] ps-[3px] text-content-muted"
style={fs(10.5, 'body')}
>
{label && <span className="ps-1">{label}</span>}
<span
className={`inline-flex h-[18px] min-w-0 flex-none items-center gap-1 rounded-full bg-surface-card px-1.5 font-geist font-semibold tabular-nums shadow-sm ${tone === 'warning' ? 'text-warning' : 'text-content'}`}
style={fs(11)}
>
{icon}
<span className="truncate">{value}</span>
</span>
</span>
);
return info ? <Tooltip label={info}>{badge}</Tooltip> : badge;
}
@@ -0,0 +1,501 @@
// FE-PADDLE-DECIDE-001 to FE-PADDLE-DECIDE-016: the decision panel of one checklist entry (PAD-06).
// FE-PADDLE-DECIDE-017 to FE-PADDLE-DECIDE-020: the panel below a paddle object's map card, wired to the checklist.
import type { ComponentProps } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ObstacleDecision, ObstacleDecisionRecord, TourObstacleCheck, TourObstacleEntry } from '@trek/shared';
import { buildSettings } from '../../../../tests/helpers/factories';
import { earlierEntry, obstacleCheck, obstacleEntry } from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { ChecklistObstaclePanel, ObstacleDecisionPanel } from './ObstacleDecisionPanel';
import { formatCheckDate } from './paddleCheckModel';
import type { PaddleChecklist } from './usePaddleChecklist';
/** What the panel says, as shared/src/i18n/en/tours.ts words it. */
const SAYS = {
bans: 'OpenStreetMap records a ban or private ground here. A decision does not lift it.',
portageMissing: 'Choosing it plans no portage. Plan the carry on the map.',
gone: 'Missing from the data is no proof that the obstacle is gone.',
supLock: 'SUP: check a portage',
supLockInfo:
'For SUP, the German canoe federation and rescue organisations advise checking a permitted portage at locks rather than locking through. This is advice, not a ban everywhere, and local rules still apply.',
};
/** The four choices in the order the panel offers them. */
const CHOICES = ['Paddle through', 'Carry round', 'Go round', 'Not affected'];
const DECIDED_AT = '2026-09-20T10:00:00.000Z';
/** The day a decision was made, with the spaces the DOM queries read. */
const DECIDED_ON = formatCheckDate(DECIDED_AT, 'en').replace(/\s/g, ' ');
/** Paddling forbidden by the canoe tag. */
const BANNED: TourObstacleEntry['access'] = {
value: 'no',
source: 'canoe',
condition: null,
forbidden: true,
private: false,
};
function record(decision: ObstacleDecision, note: string | null = null): ObstacleDecisionRecord {
return { decision, note, decided_at: DECIDED_AT, decided_by: 1 };
}
/** The weir on the route, decided about. */
function decidedEntry(decision: ObstacleDecision = 'pass', note: string | null = null): TourObstacleEntry {
return obstacleEntry({ status: 'decided', decision: record(decision, note) });
}
type PanelProps = ComponentProps<typeof ObstacleDecisionPanel>;
const onDecide = vi.fn<PanelProps['onDecide']>();
const onNote = vi.fn<PanelProps['onNote']>(() => Promise.resolve(true));
const onClear = vi.fn<PanelProps['onClear']>();
const onKeep = vi.fn<PanelProps['onKeep']>();
/** Trip member 1 is Anna; anyone else has left the trip. */
const memberName = (id: number | null) => (id === 1 ? 'Anna' : null);
/** The panel for an entry a paddler may decide about, nothing to move on to and no portage to plan. */
function panel(entry: TourObstacleEntry, props: Partial<PanelProps> = {}) {
return (
<ObstacleDecisionPanel
entry={entry}
canDecide
saving={false}
memberName={memberName}
onDecide={onDecide}
onNote={onNote}
onClear={onClear}
onKeep={onKeep}
onNext={null}
onPlanPortage={null}
{...props}
/>
);
}
const renderPanel = (entry: TourObstacleEntry, props: Partial<PanelProps> = {}) => render(panel(entry, props));
/** The notices the panel shows; it holds one at least. */
const notices = () => screen.getByTestId('obstacle-notices');
/**
* A checklist as usePaddleChecklist hands it to the panel: ready, open to
* decisions, nothing saving, entries found by their refs, every action a spy.
*/
function checklistOf(
check: TourObstacleCheck | null = obstacleCheck(),
overrides: Partial<PaddleChecklist> = {}
): PaddleChecklist {
return {
status: 'ready',
tour: null,
check,
summary: null,
stale: false,
dirty: false,
canDecide: true,
canPlanPortage: true,
savingKey: null,
selectedKey: null,
dialogKey: null,
entryForRef: (ref) => check?.entries.find((entry) => entry.refs.includes(ref)) ?? null,
select: vi.fn<PaddleChecklist['select']>(),
selectOwn: vi.fn<PaddleChecklist['selectOwn']>(),
closeDialog: vi.fn<PaddleChecklist['closeDialog']>(),
nextOf: () => null,
next: vi.fn<PaddleChecklist['next']>(),
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve(true)),
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve(true)),
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve(true)),
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve(true)),
memberName,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
seedStore(useSettingsStore, { settings: buildSettings() });
});
describe('ObstacleDecisionPanel', () => {
it('FE-PADDLE-DECIDE-001: an open entry offers the four choices as one radio group named after it, none checked', () => {
const { rerender } = renderPanel(obstacleEntry());
const group = screen.getByRole('radiogroup', { name: 'Wehr Regendorf' });
const radios = within(group).getAllByRole('radio');
expect(radios.map((radio) => radio.textContent)).toEqual(CHOICES);
for (const radio of radios) {
expect(radio).toHaveAttribute('aria-checked', 'false');
expect(radio).toBeEnabled();
}
// Nothing decided yet: no note to add, nothing to remove, nothing to warn about.
expect(screen.queryByRole('button', { name: 'Note' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove decision' })).not.toBeInTheDocument();
expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument();
// An unnamed object names the group by its kind.
rerender(panel(obstacleEntry({ name: null, kind: 'dam' })));
expect(screen.getByRole('radiogroup', { name: 'Dam' })).toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-002: a choice decides; the decided one is filled and checked, and choosing it again sends nothing', () => {
const { rerender } = renderPanel(obstacleEntry());
fireEvent.click(screen.getByRole('radio', { name: 'Carry round' }));
expect(onDecide).toHaveBeenCalledTimes(1);
expect(onDecide).toHaveBeenCalledWith('portage');
rerender(panel(decidedEntry('portage')));
const chosen = screen.getByRole('radio', { name: 'Carry round' });
expect(chosen).toHaveAttribute('aria-checked', 'true');
expect(chosen).toHaveClass('bg-accent', 'text-accent-text');
expect(screen.getByRole('radio', { name: 'Paddle through' })).toHaveAttribute('aria-checked', 'false');
expect(screen.getByRole('radio', { name: 'Paddle through' })).not.toHaveClass('bg-accent');
fireEvent.click(chosen);
expect(onDecide).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('radio', { name: 'Not affected' }));
expect(onDecide).toHaveBeenLastCalledWith('not_affected');
});
it('FE-PADDLE-DECIDE-003: in review the earlier choice keeps a dashed ring, nothing is checked, the reason shows', () => {
renderPanel(obstacleEntry({ status: 'review', changed: ['route'], decision: record('pass') }));
// Named with its review state too, so it is told apart by more than its ring.
const earlier = screen.getByRole('radio', { name: 'Paddle through, Check again' });
expect(earlier).toHaveAttribute('aria-checked', 'false');
expect(earlier).toHaveClass('border-dashed');
expect(earlier).not.toHaveClass('bg-accent');
for (const name of CHOICES.slice(1)) {
expect(screen.getByRole('radio', { name })).not.toHaveClass('border-dashed');
}
expect(screen.getByTestId('obstacle-decision-panel')).toHaveTextContent('Route changed');
// The earlier choice can be made again, against the route as it is now.
fireEvent.click(earlier);
expect(onDecide).toHaveBeenCalledWith('pass');
});
it('FE-PADDLE-DECIDE-004: a ban or private ground in the data stays a notice after every decision', () => {
const { rerender } = renderPanel(obstacleEntry({ access: BANNED }));
expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument();
rerender(panel(obstacleEntry({ access: BANNED, status: 'decided', decision: record('pass') })));
expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument();
const privateGround = { ...BANNED, value: 'private', forbidden: false, private: true };
rerender(panel(obstacleEntry({ access: privateGround, status: 'decided', decision: record('not_affected') })));
expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-005: a lock advised for SUP says so, with the explanation behind an info dot', () => {
const lock = { kind: 'lock' as const, name: 'Schleuse Regensburg' };
const { rerender } = renderPanel(obstacleEntry({ ...lock, advice: 'sup_lock_portage' }));
expect(within(notices()).getByText(SAYS.supLock)).toBeInTheDocument();
expect(within(notices()).getByRole('button', { name: SAYS.supLock })).toHaveAccessibleDescription(SAYS.supLockInfo);
rerender(panel(obstacleEntry(lock)));
expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-006: carrying round with no portage planned yet says so and offers to plan it', () => {
const onPlanPortage = vi.fn<() => void>();
const todo = obstacleEntry({ status: 'todo', decision: record('portage') });
const { rerender } = renderPanel(todo, { onPlanPortage });
expect(within(notices()).getByText(SAYS.portageMissing)).toBeInTheDocument();
fireEvent.click(within(notices()).getByRole('button', { name: 'Plan the portage' }));
expect(onPlanPortage).toHaveBeenCalledTimes(1);
// Without a way to plan it the notice stays, without the action.
rerender(panel(todo));
expect(within(notices()).getByText(SAYS.portageMissing)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Plan the portage' })).not.toBeInTheDocument();
// Going round while the route still passes is no missing portage.
rerender(panel(obstacleEntry({ status: 'todo', decision: record('reroute') }), { onPlanPortage }));
expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-007: never more than two notices; the ban and the missing portage come before the SUP advice', () => {
renderPanel(
obstacleEntry({
kind: 'lock',
advice: 'sup_lock_portage',
access: BANNED,
status: 'todo',
decision: record('portage'),
}),
{ onPlanPortage: vi.fn<() => void>() }
);
const shown = notices();
expect(within(shown).getByText(SAYS.bans)).toBeInTheDocument();
expect(within(shown).getByText(SAYS.portageMissing)).toBeInTheDocument();
expect(within(shown).queryByText(SAYS.supLock)).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-008: the check on site the data records reads as a month, and as unknown when there is none', () => {
const { rerender } = renderPanel(obstacleEntry({ local_check: '2025-06' }));
const badge = screen.getByTestId('obstacle-on-site');
expect(badge).toHaveTextContent('On site');
expect(badge).toHaveTextContent(formatCheckDate('2025-06', 'en').replace(/\s/g, ' '));
rerender(panel(obstacleEntry()));
expect(screen.getByTestId('obstacle-on-site')).toHaveTextContent('unknown');
});
it('FE-PADDLE-DECIDE-009: a decision takes a note of two rows and at most 500 characters, saved trimmed', async () => {
renderPanel(decidedEntry());
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
const box = screen.getByRole('textbox', { name: 'Note' });
expect(box).toHaveAttribute('rows', '2');
expect(box).toHaveAttribute('maxlength', '500');
expect(box).toHaveFocus();
fireEvent.change(box, { target: { value: ' Links an der Bootsrutsche ' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onNote).toHaveBeenCalledTimes(1);
expect(onNote).toHaveBeenCalledWith('Links an der Bootsrutsche');
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
});
it('FE-PADDLE-DECIDE-021: a note whose save failed stays in the editor with its text', async () => {
onNote.mockResolvedValueOnce(false);
renderPanel(decidedEntry());
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: 'Rechts umtragen' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(onNote).toHaveBeenCalledWith('Rechts umtragen'));
await Promise.resolve();
expect(screen.getByRole('textbox', { name: 'Note' })).toHaveValue('Rechts umtragen');
});
it('FE-PADDLE-DECIDE-010: a saved note reads clamped; Cancel and an unchanged save keep it, emptying it removes it', () => {
renderPanel(decidedEntry('pass', 'Bootsrutsche am linken Ufer'));
expect(screen.getByText('Bootsrutsche am linken Ufer')).toHaveClass('line-clamp-3');
expect(screen.queryByRole('button', { name: 'Note' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
const box = screen.getByRole('textbox', { name: 'Note' });
expect(box).toHaveValue('Bootsrutsche am linken Ufer');
fireEvent.change(box, { target: { value: 'Anders' } });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onNote).not.toHaveBeenCalled();
expect(screen.getByText('Bootsrutsche am linken Ufer')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onNote).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: ' ' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onNote).toHaveBeenCalledWith(null);
});
it('FE-PADDLE-DECIDE-011: Escape in the note ends the edit and goes no further, so the map card around it stays open', () => {
const parent = vi.fn<() => void>();
const onDocument = vi.fn<(event: KeyboardEvent) => void>();
document.addEventListener('keydown', onDocument);
try {
render(<div onKeyDown={parent}>{panel(decidedEntry())}</div>);
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
const box = screen.getByRole('textbox', { name: 'Note' });
// Any other key reaches the card and the page as always.
fireEvent.keyDown(box, { key: 'a' });
expect(parent).toHaveBeenCalledTimes(1);
expect(onDocument).toHaveBeenCalledTimes(1);
const delivered = fireEvent.keyDown(box, { key: 'Escape' });
expect(delivered).toBe(false);
expect(parent).toHaveBeenCalledTimes(1);
expect(onDocument).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(onNote).not.toHaveBeenCalled();
} finally {
document.removeEventListener('keydown', onDocument);
}
});
it('FE-PADDLE-DECIDE-012: the foot says who decided when, removes the decision and moves on to the next entry', () => {
const onNext = vi.fn<() => void>();
const { rerender } = renderPanel(decidedEntry(), { onNext });
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
expect(onClear).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onNext).toHaveBeenCalledTimes(1);
// Someone who left the trip leaves the date alone; without a next entry there is no way on.
rerender(panel(obstacleEntry({ status: 'decided', decision: { ...record('pass'), decided_by: 7 } })));
expect(screen.getByText(`Decided ${DECIDED_ON}`)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument();
// An open entry moves on all the same.
rerender(panel(obstacleEntry(), { onNext }));
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
expect(screen.queryByText(/^Decided/)).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-013: an object the data no longer holds offers keeping or removing its decision, no choices', () => {
renderPanel(earlierEntry());
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
expect(screen.getByText(SAYS.gone)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Keep decision' }));
expect(onKeep).toHaveBeenCalledTimes(1);
// One way to remove it: the foot leaves it to the actions.
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
expect(onClear).toHaveBeenCalledTimes(1);
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-014: an earlier decision the route no longer meets has no choices to make, yet can be removed', () => {
renderPanel(earlierEntry({ status: 'off_route' }));
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
expect(screen.queryByText(SAYS.gone)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Keep decision' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
expect(onClear).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-DECIDE-015: a viewer reads the panel: the choices, the note and removing are off, moving on is not', () => {
const onNext = vi.fn<() => void>();
renderPanel(decidedEntry('pass', 'Bootsrutsche'), { canDecide: false, onNext });
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
fireEvent.click(screen.getByRole('radio', { name: 'Go round' }));
expect(onDecide).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Remove decision' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
});
it('FE-PADDLE-DECIDE-016: while its change saves the choices wait, and a viewer cannot keep or remove a vanished one', () => {
const { rerender } = renderPanel(obstacleEntry(), { saving: true });
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
expect(screen.getByTestId('obstacle-decision-panel')).toHaveAttribute('aria-busy', 'true');
rerender(panel(earlierEntry(), { canDecide: false }));
expect(screen.getByRole('button', { name: 'Keep decision' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Remove decision' })).toBeDisabled();
});
});
describe('ChecklistObstaclePanel', () => {
it('FE-PADDLE-DECIDE-017: shows nothing without a checklist or for an object it does not list, and stays while it reads again', () => {
const { container, rerender } = render(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/2" />);
expect(container).toBeEmptyDOMElement();
rerender(<ChecklistObstaclePanel checklist={checklistOf(null, { status: 'loading' })} featureRef="node/1" />);
expect(container).toBeEmptyDOMElement();
// Reading again keeps the panel open, locked, so a note being written is not lost.
rerender(
<ChecklistObstaclePanel
checklist={checklistOf(obstacleCheck(), { status: 'loading', canDecide: false })}
featureRef="node/1"
/>
);
expect(screen.getByTestId('obstacle-decision-panel')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Paddle through' })).toBeDisabled();
rerender(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/1" />);
expect(screen.getByTestId('obstacle-decision-panel')).toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-018: decides, plans the portage, notes, removes and moves on through the checklist', async () => {
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
const following = obstacleEntry({ key: 'node/2', refs: ['node/2'], name: 'Wehr Pielenhofen', along_m: 3400 });
const checklist = checklistOf(obstacleCheck({ entries: [entry, following] }), {
nextOf: (item) => (item.key === entry.key ? following : null),
});
render(<ChecklistObstaclePanel checklist={checklist} featureRef="node/1" />);
fireEvent.click(screen.getByRole('radio', { name: 'Go round' }));
expect(checklist.decide).toHaveBeenCalledWith(entry, 'reroute');
fireEvent.click(screen.getByRole('button', { name: 'Plan the portage' }));
expect(checklist.planPortage).toHaveBeenCalledWith(entry);
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: 'Rechts aussteigen' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(checklist.setNote).toHaveBeenCalledWith(entry, 'Rechts aussteigen');
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
expect(checklist.clear).toHaveBeenCalledWith(entry);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(checklist.next).toHaveBeenCalledWith(entry);
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
});
it('FE-PADDLE-DECIDE-022: someone who may not edit the tour is not offered to plan the portage', () => {
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
render(
<ChecklistObstaclePanel
checklist={checklistOf(obstacleCheck({ entries: [entry] }), { canPlanPortage: false, canDecide: false })}
featureRef="node/1"
/>
);
expect(screen.queryByRole('button', { name: 'Plan the portage' })).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-023: moving on keeps the focus in the panel when the next entry has no Next of its own', () => {
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
const following = obstacleEntry({ key: 'node/2', refs: ['node/2'], name: 'Wehr Pielenhofen', along_m: 3400 });
const check = obstacleCheck({ entries: [entry, following] });
let featureRef = 'node/1';
const checklist = checklistOf(check, {
nextOf: (item) => (item.key === entry.key ? following : null),
next: vi.fn<PaddleChecklist['next']>(() => {
featureRef = 'node/2';
}),
});
const { rerender } = render(<ChecklistObstaclePanel checklist={checklist} featureRef={featureRef} />);
const next = screen.getByRole('button', { name: 'Next' });
next.focus();
fireEvent.click(next);
rerender(<ChecklistObstaclePanel checklist={checklist} featureRef={featureRef} />);
expect(screen.getByTestId('obstacle-decision-panel')).toHaveFocus();
});
it('FE-PADDLE-DECIDE-019: a decision about an object the data no longer holds is kept through the checklist', () => {
const gone = earlierEntry();
const checklist = checklistOf(obstacleCheck({ entries: [obstacleEntry(), gone] }));
render(<ChecklistObstaclePanel checklist={checklist} featureRef="node/9" />);
fireEvent.click(screen.getByRole('button', { name: 'Keep decision' }));
expect(checklist.keep).toHaveBeenCalledWith(gone);
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-020: the entry whose change is saving waits, as every entry does when the checklist cannot decide', () => {
const check = obstacleCheck({ entries: [obstacleEntry(), obstacleEntry({ key: 'node/2', refs: ['node/2'] })] });
const { rerender } = render(
<ChecklistObstaclePanel checklist={checklistOf(check, { savingKey: 'node/2' })} featureRef="node/1" />
);
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeEnabled();
rerender(<ChecklistObstaclePanel checklist={checklistOf(check, { savingKey: 'node/1' })} featureRef="node/1" />);
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
rerender(<ChecklistObstaclePanel checklist={checklistOf(check, { canDecide: false })} featureRef="node/1" />);
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
});
});
@@ -0,0 +1,472 @@
import {
OBSTACLE_DECISIONS,
type ObstacleDecision,
type ObstacleDecisionRecord,
type TourObstacleEntry,
} from '@trek/shared';
import {
ArrowRight,
Ban,
Check,
LifeBuoy,
MoveRight,
Pencil,
SearchX,
Split,
StickyNote,
Trash2,
Unlink,
X,
} from 'lucide-react';
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, InfoDot, TourNotice } from '../tourParts';
import { FigureBadge } from './FigureBadge';
import { ObstacleStatusBadges } from './PaddleCheckBadges';
import { PortageIcon } from './PaddleGlyph';
import { formatCheckDate } from './paddleCheckModel';
import type { PaddleChecklist } from './usePaddleChecklist';
type Translate = (key: string, params?: Record<string, string | number>) => string;
/** The longest note a decision keeps, as the checklist contract allows it. */
const NOTE_MAX = 500;
/** The notices an entry can call for, in the order they are shown: a ban first, since no decision lifts it. */
const NOTICE_ORDER = ['bans', 'portage', 'sup'] as const;
type NoticeId = (typeof NOTICE_ORDER)[number];
/** The most notices the panel shows at once. */
const MAX_NOTICES = 2;
const CHOICE_ICON: Record<ObstacleDecision, ReactNode> = {
pass: <MoveRight size={14} strokeWidth={2.2} className="rtl:rotate-180" />,
portage: <PortageIcon size={14} />,
reroute: <Split size={14} strokeWidth={2.2} />,
not_affected: <Unlink size={14} strokeWidth={2.2} />,
};
/** How a choice looks: decided, decided before the route or the data changed, or not chosen. */
type ChoiceLook = 'chosen' | 'earlier' | 'idle';
const CHOICE_LOOK: Record<ChoiceLook, string> = {
chosen: 'border-transparent bg-accent text-accent-text',
earlier: 'border-dashed border-accent bg-surface-card text-content',
idle: 'border-edge-faint bg-surface-card text-content-secondary enabled:hover:text-content',
};
const CHOICE =
'flex min-w-0 items-center gap-1.5 rounded-[10px] border px-2.5 py-1.5 text-start font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50';
const QUIET =
'rounded-sm font-semibold text-content-muted underline-offset-2 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:text-content enabled:hover:underline disabled:cursor-default disabled:opacity-40';
const ICON_BUTTON =
'grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:bg-surface-hover enabled:hover:text-content disabled:cursor-default disabled:opacity-40';
const SAVE_BUTTON =
'inline-flex h-7 items-center gap-1.5 rounded-full bg-accent px-2.5 font-semibold text-accent-text shadow-sm transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:opacity-90 disabled:cursor-default disabled:opacity-40';
/** The four choices as a radio group of two by two: the decided one filled, in review the earlier one dashed. */
function DecisionChoices({
entry,
label,
disabled,
onDecide,
}: {
entry: TourObstacleEntry;
label: string;
disabled: boolean;
onDecide: (decision: ObstacleDecision) => void;
}) {
const { t } = useTranslation();
const decided = entry.decision?.decision ?? null;
// In review the earlier choice no longer holds: it is shown, not checked, and can be made again.
const reviewing = entry.status === 'review';
const lookOf = (choice: ObstacleDecision): ChoiceLook => {
if (choice !== decided) return 'idle';
return reviewing ? 'earlier' : 'chosen';
};
return (
<div role="radiogroup" aria-label={label} className="grid grid-cols-2 gap-1.5">
{OBSTACLE_DECISIONS.map((choice) => {
const look = lookOf(choice);
return (
<button
key={choice}
type="button"
role="radio"
aria-checked={look === 'chosen'}
disabled={disabled}
onClick={() => {
if (look !== 'chosen') onDecide(choice);
}}
className={`${CHOICE} ${CHOICE_LOOK[look]}`}
style={fs(11.5, 'body')}
>
<span className="flex-none">{CHOICE_ICON[choice]}</span>
<span className="min-w-0 truncate">{t(`tours.paddle.check.decision.${choice}`)}</span>
{/* The earlier choice is told apart by more than its dashed ring. */}
{look === 'earlier' && <span className="sr-only">{`, ${t('tours.paddle.check.status.review')}`}</span>}
</button>
);
})}
</div>
);
}
/** Which notices an entry calls for, at most MAX_NOTICES of them. */
function noticesOf(entry: TourObstacleEntry): NoticeId[] {
const wanted: Record<NoticeId, boolean> = {
bans: entry.access.forbidden || entry.access.private,
portage: entry.status === 'todo' && entry.decision?.decision === 'portage',
sup: entry.advice === 'sup_lock_portage',
};
return NOTICE_ORDER.filter((id) => wanted[id]).slice(0, MAX_NOTICES);
}
/** What stays true whatever was chosen: a ban in the data, a portage not planned yet, the SUP advice at a lock. */
function PanelNotices({ entry, onPlanPortage }: { entry: TourObstacleEntry; onPlanPortage: (() => void) | null }) {
const { t } = useTranslation();
const shown = noticesOf(entry);
if (shown.length === 0) return null;
const notices: Record<NoticeId, ReactNode> = {
bans: (
<TourNotice key="bans" icon={<Ban size={13} strokeWidth={2} />}>
{t('tours.paddle.check.bansInfo')}
</TourNotice>
),
portage: (
<TourNotice key="portage" icon={<PortageIcon size={13} />}>
<div className="flex flex-col items-start gap-1.5">
<span>{t('tours.paddle.check.portageMissingInfo')}</span>
{onPlanPortage && (
<CardAction icon={<PortageIcon size={12} />} onClick={onPlanPortage}>
{t('tours.paddle.check.planPortage')}
</CardAction>
)}
</div>
</TourNotice>
),
sup: (
<TourNotice key="sup" icon={<LifeBuoy size={13} strokeWidth={2} />}>
<div className="flex items-center gap-1">
<span className="min-w-0">{t('tours.paddle.check.supLock')}</span>
<span className="-my-1 flex-none">
<InfoDot label={t('tours.paddle.check.supLock')} description={t('tours.paddle.check.supLockInfo')} />
</span>
</div>
</TourNotice>
),
};
return (
<div data-testid="obstacle-notices" className="flex flex-col gap-2">
{shown.map((id) => notices[id])}
</div>
);
}
/** An entry the data no longer holds: why that proves nothing, and keeping or removing its decision. */
function GoneActions({ disabled, onKeep, onClear }: { disabled: boolean; onKeep: () => void; onClear: () => void }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<TourNotice icon={<SearchX size={13} strokeWidth={2} />}>{t('tours.paddle.check.goneInfo')}</TourNotice>
<div className="flex flex-wrap items-center gap-1.5">
<CardAction icon={<Check size={12} strokeWidth={2.4} />} onClick={onKeep} disabled={disabled}>
{t('tours.paddle.check.keep')}
</CardAction>
<CardAction icon={<Trash2 size={12} strokeWidth={2.2} />} onClick={onClear} disabled={disabled}>
{t('tours.paddle.check.clear')}
</CardAction>
</div>
</div>
);
}
/** Writing the note in two rows; Escape drops the edit without closing the map card or the dialog around it. */
function NoteEditor({
draft,
disabled,
onChange,
onSave,
onCancel,
}: {
draft: string;
disabled: boolean;
onChange: (draft: string) => void;
onSave: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const box = useRef<HTMLTextAreaElement>(null);
// The note opens for writing, so it takes the focus once, when it opens.
useEffect(() => {
box.current?.focus();
}, []);
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== 'Escape') return;
// The map card and the dialog close on Escape; inside the note it only ends the edit.
event.preventDefault();
event.stopPropagation();
onCancel();
};
return (
<div className="flex flex-col gap-1.5">
<textarea
rows={2}
maxLength={NOTE_MAX}
ref={box}
value={draft}
aria-label={t('tours.paddle.check.note')}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
className="w-full resize-none rounded-[10px] border border-edge bg-surface-input px-2.5 py-1.5 text-content outline-none focus:border-accent"
style={fs(12, 'body')}
/>
<div className="flex items-center justify-end gap-1.5">
<CardAction icon={<X size={12} strokeWidth={2.4} />} onClick={onCancel}>
{t('common.cancel')}
</CardAction>
<button type="button" onClick={onSave} disabled={disabled} className={SAVE_BUTTON} style={fs(11.5, 'body')}>
<Check size={12} strokeWidth={2.4} aria-hidden="true" />
{t('common.save')}
</button>
</div>
</div>
);
}
/** The decision's note: read clamped with a way to edit it, or a way to add one. */
function NoteBlock({
note,
disabled,
onNote,
}: {
note: string | null;
disabled: boolean;
/** Resolves to whether the note was saved; a failed save keeps the text being written. */
onNote: (note: string | null) => Promise<boolean>;
}) {
const { t } = useTranslation();
// The text being written, or null while the note is only read.
const [draft, setDraft] = useState<string | null>(null);
const save = (text: string) => {
const next = text.trim() || null;
if (next === note) {
setDraft(null);
return;
}
void onNote(next).then((saved) => {
if (saved) setDraft(null);
});
};
if (draft !== null)
return (
<NoteEditor
draft={draft}
disabled={disabled}
onChange={setDraft}
onSave={() => save(draft)}
onCancel={() => setDraft(null)}
/>
);
if (!note)
return (
<div>
<CardAction icon={<StickyNote size={12} strokeWidth={2.2} />} onClick={() => setDraft('')} disabled={disabled}>
{t('tours.paddle.check.note')}
</CardAction>
</div>
);
return (
<div className="flex items-start gap-1.5">
<p
className="m-0 line-clamp-3 min-w-0 flex-1 break-words text-content-muted"
style={fs(11.5, 'body')}
title={note}
>
{note}
</p>
<Tooltip label={t('common.edit')}>
<button
type="button"
aria-label={t('common.edit')}
onClick={() => setDraft(note)}
disabled={disabled}
className={ICON_BUTTON}
>
<Pencil size={12} strokeWidth={2.2} aria-hidden="true" />
</button>
</Tooltip>
</div>
);
}
/** Who decided and when: the trip member by name while they are on the trip, otherwise the date alone. */
function decidedLine(
decision: ObstacleDecisionRecord,
memberName: (id: number | null) => string | null,
t: Translate,
locale: string
): string {
const date = formatCheckDate(decision.decided_at, locale);
const name = memberName(decision.decided_by);
return name ? t('tours.paddle.check.decidedBy', { date, name }) : t('tours.paddle.check.decidedAt', { date });
}
/** The foot of the panel: who decided when, removing the decision, and the way on to the next open entry. */
function MetaLine({
decision,
memberName,
clearable,
disabled,
onClear,
onNext,
}: {
decision: ObstacleDecisionRecord | null;
memberName: (id: number | null) => string | null;
clearable: boolean;
disabled: boolean;
onClear: () => void;
onNext: (() => void) | null;
}) {
const { t, locale } = useTranslation();
if (!decision && !onNext) return null;
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-content-faint" style={fs(10.5, 'body')}>
{decision && <span className="min-w-0">{decidedLine(decision, memberName, t, locale)}</span>}
{decision && clearable && (
<button type="button" onClick={onClear} disabled={disabled} className={QUIET}>
{t('tours.paddle.check.clear')}
</button>
)}
{onNext && (
<span className="ms-auto">
<CardAction icon={<ArrowRight size={12} strokeWidth={2.4} className="rtl:rotate-180" />} onClick={onNext}>
{t('tours.paddle.check.next')}
</CardAction>
</span>
)}
</div>
);
}
/**
* PAD-06: what the paddler decides about one obstacle of the checklist, below
* its map card or in its dialog. The four choices, the notices that stay
* whatever was chosen (a ban in the data, a portage not planned yet, the SUP
* advice at a lock), the check on site the data records, a note, and who
* decided when. An object the data no longer holds offers keeping or removing
* its decision instead of the choices. A decision is the paddler's choice,
* never a safety or legal clearance.
*/
export function ObstacleDecisionPanel({
entry,
canDecide,
saving,
memberName,
onDecide,
onNote,
onClear,
onKeep,
onNext,
onPlanPortage,
}: {
entry: TourObstacleEntry;
canDecide: boolean;
/** This entry's change is on its way to the server. */
saving: boolean;
memberName: (id: number | null) => string | null;
onDecide: (decision: ObstacleDecision) => void;
onNote: (note: string | null) => Promise<boolean>;
onClear: () => void;
onKeep: () => void;
/** Moves on to the next entry still asking for a look; null when there is none. */
onNext: (() => void) | null;
/** Starts planning the portage round the obstacle; null where that cannot be offered. */
onPlanPortage: (() => void) | null;
}) {
const { t, locale } = useTranslation();
const locked = !canDecide || saving;
const gone = entry.status === 'gone';
const label = entry.name ?? t(`tours.paddle.obstacle.kind.${entry.kind}`);
const root = useRef<HTMLDivElement>(null);
const movedOn = useRef(false);
// Moving on keeps the focus in the panel, also when the next entry has no Next button of its own.
useLayoutEffect(() => {
if (!movedOn.current) return;
movedOn.current = false;
if (!root.current?.contains(document.activeElement)) root.current?.focus({ preventScroll: true });
});
const next = onNext
? () => {
movedOn.current = true;
onNext();
}
: null;
return (
<div
ref={root}
tabIndex={-1}
data-testid="obstacle-decision-panel"
aria-busy={saving || undefined}
className="flex flex-col gap-2.5 outline-none"
>
<div className="flex flex-wrap items-center gap-1.5 empty:hidden">
<ObstacleStatusBadges entry={entry} />
</div>
{entry.relation !== null && <DecisionChoices entry={entry} label={label} disabled={locked} onDecide={onDecide} />}
{gone && <GoneActions disabled={locked} onKeep={onKeep} onClear={onClear} />}
<PanelNotices entry={entry} onPlanPortage={onPlanPortage} />
<div className="flex">
<FigureBadge
testId="obstacle-on-site"
label={t('tours.paddle.check.onSite')}
value={entry.local_check ? formatCheckDate(entry.local_check, locale) : t('tours.paddle.check.unknown')}
/>
</div>
{entry.decision && <NoteBlock key={entry.key} note={entry.decision.note} disabled={locked} onNote={onNote} />}
<MetaLine
decision={entry.decision}
memberName={memberName}
clearable={!gone}
disabled={locked}
onClear={onClear}
onNext={next}
/>
</div>
);
}
/** The decision panel of one checklist entry wired to the checklist; the map card and the entry dialog share it. */
export function ChecklistEntryPanel({ checklist, entry }: { checklist: PaddleChecklist; entry: TourObstacleEntry }) {
return (
<ObstacleDecisionPanel
entry={entry}
canDecide={checklist.canDecide}
saving={checklist.savingKey === entry.key}
memberName={checklist.memberName}
onDecide={(decision) => void checklist.decide(entry, decision)}
onNote={(note) => checklist.setNote(entry, note)}
onClear={() => void checklist.clear(entry)}
onKeep={() => void checklist.keep(entry)}
onNext={checklist.nextOf(entry) ? () => checklist.next(entry) : null}
onPlanPortage={checklist.canPlanPortage ? () => checklist.planPortage(entry) : null}
/>
);
}
/**
* The checklist's part of the map card of a paddle object (PAD-06): the
* decision panel of the entry the object belongs to, below its facts, or
* nothing while there is no checklist or it does not list the object.
*/
export function ChecklistObstaclePanel({ checklist, featureRef }: { checklist: PaddleChecklist; featureRef: string }) {
// While the checklist reads again it keeps showing, locked, so the panel open on an object stays open.
const entry = checklist.check ? checklist.entryForRef(featureRef) : null;
if (!entry) return null;
return (
<div className="mt-3 border-t border-edge-faint pt-3">
<ChecklistEntryPanel checklist={checklist} entry={entry} />
</div>
);
}
@@ -0,0 +1,159 @@
// FE-PADDLE-ENTRY-DIALOG-001 to FE-PADDLE-ENTRY-DIALOG-007: the dialog of a checklist entry the map holds no card for.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TourObstacleCheck, TourObstacleEntry } from '@trek/shared';
import { buildSettings } from '../../../../tests/helpers/factories';
import { earlierEntry, obstacleCheck, obstacleEntry } from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { ObstacleEntryDialog } from './ObstacleEntryDialog';
import type { PaddleChecklist } from './usePaddleChecklist';
const OSM = 'View in OpenStreetMap';
/**
* A checklist as usePaddleChecklist hands it to the dialog: ready, open to
* decisions, no dialog open unless told, every action a spy.
*/
function checklistOf(
check: TourObstacleCheck | null = obstacleCheck(),
overrides: Partial<PaddleChecklist> = {}
): PaddleChecklist {
return {
status: 'ready',
tour: null,
check,
summary: null,
stale: false,
dirty: false,
canDecide: true,
canPlanPortage: true,
savingKey: null,
selectedKey: null,
dialogKey: null,
entryForRef: (ref) => check?.entries.find((entry) => entry.refs.includes(ref)) ?? null,
select: vi.fn<PaddleChecklist['select']>(),
selectOwn: vi.fn<PaddleChecklist['selectOwn']>(),
closeDialog: vi.fn<PaddleChecklist['closeDialog']>(),
nextOf: () => null,
next: vi.fn<PaddleChecklist['next']>(),
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve(true)),
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve(true)),
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve(true)),
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve(true)),
memberName: (id) => (id === 1 ? 'Anna' : null),
...overrides,
};
}
/** The dialog opened for an entry the way the checklist opens it: by its key. */
function openFor(entry: TourObstacleEntry): PaddleChecklist {
const checklist = checklistOf(obstacleCheck({ entries: [entry] }), { dialogKey: entry.key });
render(<ObstacleEntryDialog checklist={checklist} />);
return checklist;
}
/** The head band of the dialog, apart from the panel in its body. */
function headerOf(dialog: HTMLElement): HTMLElement {
const header = dialog.querySelector('header');
if (!header) throw new Error('the dialog has no head band');
return header;
}
beforeEach(() => {
vi.clearAllMocks();
// No distance unit chosen: the dialog reads metric.
seedStore(useSettingsStore, { settings: buildSettings() });
});
describe('ObstacleEntryDialog', () => {
it('FE-PADDLE-ENTRY-DIALOG-001: stays closed without a dialog key, for a key the checklist lacks and without a checklist', () => {
const { rerender } = render(<ObstacleEntryDialog checklist={checklistOf()} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<ObstacleEntryDialog checklist={checklistOf(obstacleCheck(), { dialogKey: 'node/404' })} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<ObstacleEntryDialog checklist={checklistOf(null, { dialogKey: 'node/1' })} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-PADDLE-ENTRY-DIALOG-002: names the obstacle and its kind, how far along the route it lies, and links to OSM', () => {
openFor(obstacleEntry());
const header = headerOf(screen.getByRole('dialog', { name: 'Wehr Regendorf' }));
expect(header).toHaveTextContent('Weir');
expect(header).toHaveTextContent('1.2 km');
const link = within(header).getByRole('link', { name: OSM });
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/node/1');
expect(link).toHaveAttribute('target', '_blank');
expect(link.getAttribute('rel')).toContain('noreferrer');
});
it('FE-PADDLE-ENTRY-DIALOG-003: its body is the decision panel, wired to the checklist', () => {
const entry = obstacleEntry();
const checklist = openFor(entry);
const dialog = screen.getByRole('dialog', { name: 'Wehr Regendorf' });
expect(within(dialog).getByTestId('obstacle-decision-panel')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('radio', { name: 'Carry round' }));
expect(checklist.decide).toHaveBeenCalledWith(entry, 'portage');
});
it('FE-PADDLE-ENTRY-DIALOG-004: an object the data no longer holds gives no distance and offers keeping its decision', () => {
const gone = earlierEntry();
const checklist = openFor(gone);
const dialog = screen.getByRole('dialog', { name: 'Alte Sohlschwelle' });
const header = headerOf(dialog);
expect(header).not.toHaveTextContent('km');
expect(within(header).getByRole('link', { name: OSM })).toHaveAttribute(
'href',
'https://www.openstreetmap.org/node/9'
);
expect(within(dialog).queryByRole('radiogroup')).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep decision' }));
expect(checklist.keep).toHaveBeenCalledWith(gone);
});
it('FE-PADDLE-ENTRY-DIALOG-005: an unnamed obstacle is titled by its kind and links its nearest object', () => {
openFor(obstacleEntry({ key: 'lock:way/42', refs: ['way/42', 'way/43'], kind: 'lock', name: null, along_m: 800 }));
const header = headerOf(screen.getByRole('dialog', { name: 'Lock' }));
expect(within(header).getByRole('heading', { name: 'Lock' })).toBeInTheDocument();
expect(header).toHaveTextContent('800 m');
expect(within(header).getByRole('link', { name: OSM })).toHaveAttribute(
'href',
'https://www.openstreetmap.org/way/42'
);
});
it('FE-PADDLE-ENTRY-DIALOG-006: the distance follows the chosen unit', () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
openFor(obstacleEntry());
expect(headerOf(screen.getByRole('dialog', { name: 'Wehr Regendorf' }))).toHaveTextContent('0.7 mi');
});
it('FE-PADDLE-ENTRY-DIALOG-007: the close button and Escape close it, while Escape in the note only ends the note', () => {
const decided = obstacleEntry({
status: 'decided',
decision: { decision: 'pass', note: null, decided_at: '2026-09-20T10:00:00.000Z', decided_by: 1 },
});
const checklist = openFor(decided);
const dialog = screen.getByRole('dialog', { name: 'Wehr Regendorf' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Note' }));
fireEvent.keyDown(within(dialog).getByRole('textbox', { name: 'Note' }), { key: 'Escape' });
expect(within(dialog).queryByRole('textbox')).not.toBeInTheDocument();
expect(checklist.closeDialog).not.toHaveBeenCalled();
fireEvent.keyDown(document, { key: 'Escape' });
expect(checklist.closeDialog).toHaveBeenCalledTimes(1);
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }));
expect(checklist.closeDialog).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,75 @@
import type { TourObstacleEntry } from '@trek/shared';
import { ExternalLink } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT, PILL } from '../../shared/DialogShell';
import { ChecklistEntryPanel } from './ObstacleDecisionPanel';
import { ChecklistGlyph } from './PaddleCheckBadges';
import { osmUrlOf } from './paddleObstacleModel';
import type { PaddleChecklist } from './usePaddleChecklist';
/** The pills of the head band: how far along the route the object lies and the object on OpenStreetMap. */
function EntryPills({ entry }: { entry: TourObstacleEntry }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const ref = entry.refs[0];
return (
<>
{entry.along_m !== null && (
<span className={`${PILL} tabular-nums`}>{formatDistance(entry.along_m / 1000, unit)}</span>
)}
{ref && (
<a href={osmUrlOf(ref)} target="_blank" rel="noopener noreferrer" className={`${PILL} hover:underline`}>
{t('ferratas.viewInOsm')}
<ExternalLink size={12} strokeWidth={2.2} aria-hidden="true" />
</a>
)}
</>
);
}
/** The dialog for an entry the checklist holds: its head band, then the decision panel. */
function EntryDialog({ checklist, entry }: { checklist: PaddleChecklist; entry: TourObstacleEntry }) {
const { t } = useTranslation();
const labelId = useId();
const kind = t(`tours.paddle.obstacle.kind.${entry.kind}`);
return (
<DialogShell
onClose={checklist.closeDialog}
labelledBy={labelId}
width="narrow"
header={
<DialogHeader
tile={
<DialogTile>
<ChecklistGlyph kind={entry.kind} dashed={entry.relation === 'near'} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={checklist.closeDialog}
title={entry.name ?? kind}
sub={entry.name ? kind : undefined}
pills={<EntryPills entry={entry} />}
/>
}
>
<ChecklistEntryPanel checklist={checklist} entry={entry} />
</DialogShell>
);
}
/**
* PAD-06: one entry of the checklist in a narrow dialog, for an object the
* map holds no card for (offline, or an earlier decision the data or the
* route no longer carries): what it is, how far along the route it lies, its
* object on OpenStreetMap and the same decision panel the map card carries.
*/
export function ObstacleEntryDialog({ checklist }: { checklist: PaddleChecklist }) {
const { check, dialogKey } = checklist;
const entry = dialogKey === null ? null : (check?.entries.find((item) => item.key === dialogKey) ?? null);
if (!entry) return null;
return <EntryDialog checklist={checklist} entry={entry} />;
}
@@ -0,0 +1,421 @@
// PCB-001 to PCB-033: the badges of a paddle tour's checklist (PAD-06, PAD-09): the bench as one pill, how far its
// data reaches and how current it is, the ring a row's symbol sits in, where an entry stands and its paddling rule.
import { describe, expect, it } from 'vitest';
import type { ObstacleDecision, PaddleCheck, PaddleCheckData, TourObstacleEntry } from '@trek/shared';
import { earlierEntry, obstacleEntry, paddleBench } from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import {
ChecklistGlyph,
ObstacleAccessBadge,
ObstacleStatusBadges,
PaddleCheckDataLine,
PaddleCheckPill,
} from './PaddleCheckBadges';
import { formatCheckDate } from './paddleCheckModel';
const NOT_CLEARANCE = 'A checklist is no safety or legal clearance.';
const BANS_INFO = 'OpenStreetMap records a ban or private ground here. A decision does not lift it.';
const GONE_INFO = 'Missing from the data is no proof that the obstacle is gone.';
/** A conditional rule as OpenStreetMap writes it: shown as written, never evaluated. */
const CONDITION = 'no @ (Nov-Mar)';
/** The dates of a bench as the fixtures carry them, and as the badges word them in the suite's locale (en-US). */
const DATA_AS_OF = '2026-10-01T00:00:00.000Z';
const READ_AT = '2026-10-02T04:20:00.000Z';
const AS_OF = formatCheckDate(DATA_AS_OF, 'en-US');
const READ = formatCheckDate(READ_AT, 'en-US');
/** No paddling rule in the tags at all. */
const NO_RULE: TourObstacleEntry['access'] = {
value: null,
source: null,
condition: null,
forbidden: false,
private: false,
};
function decisionOf(decision: ObstacleDecision): TourObstacleEntry['decision'] {
return { decision, note: null, decided_at: '2026-10-10T10:00:00.000Z', decided_by: 1 };
}
/** What an element's tooltip says once it is hovered; the hover ends before it returns. */
async function tooltipOf(element: HTMLElement): Promise<string | null> {
fireEvent.mouseEnter(element);
const text = (await screen.findByRole('tooltip')).textContent;
fireEvent.mouseLeave(element);
return text;
}
function pillOf(check: PaddleCheck): HTMLElement {
render(<PaddleCheckPill check={check} />);
return screen.getByTestId('plan-tour-paddle-check');
}
interface Precedence {
id: string;
rule: string;
bench: Partial<PaddleCheck>;
value: string;
icon: string;
}
const PRECEDENCE: Precedence[] = [
{
id: 'PCB-002',
rule: 'unavailable data says so over every count and every ban',
bench: { data: 'unavailable', open: 2, review: 1, bans: 1 },
value: 'Unavailable',
icon: 'circle-dashed',
},
{
id: 'PCB-003',
rule: 'no data says so and never shows a count',
bench: { data: 'none', open: 2, decided: 3 },
value: 'No data',
icon: 'circle-dashed',
},
{
id: 'PCB-004',
rule: 'entries open or to check again come before a partial coverage, counted together',
bench: { data: 'partial', open: 1, review: 2 },
value: '3 open',
icon: 'list-checks',
},
{
id: 'PCB-005',
rule: 'partial data with nothing pending names the coverage',
bench: { data: 'partial', open: 0, decided: 2 },
value: 'Partial',
icon: 'list-checks',
},
{
id: 'PCB-006',
rule: 'data cut short with nothing pending names it',
bench: { data: 'limited', open: 0 },
value: 'Cut short',
icon: 'list-checks',
},
{
id: 'PCB-007',
rule: 'a route without known obstacles says none known, never clear',
bench: { entries: 0, open: 0 },
value: 'None known',
icon: 'list-checks',
},
{
id: 'PCB-008',
rule: 'every entry decided counts the decisions',
bench: { entries: 2, open: 0, decided: 2 },
value: '2 decided',
icon: 'list-checks',
},
];
describe('PaddleCheckPill', () => {
it('PCB-001: an open entry on covered data reads as open and is named by its counts and the caveat', () => {
const pill = pillOf(paddleBench());
expect(pill.textContent).toBe('1 open');
expect(pill).toHaveAttribute('aria-label', `1 open\n${NOT_CLEARANCE}`);
expect(pill.querySelector('svg.lucide-list-checks')).not.toBeNull();
});
it.each(PRECEDENCE)('$id: $rule', ({ bench, value, icon }) => {
const pill = pillOf(paddleBench(bench));
expect(pill.textContent).toBe(value);
expect(pill.querySelector(`svg.lucide-${icon}`)).not.toBeNull();
});
it('PCB-009: the tooltip lists the data, every count, the ban and that a checklist is no clearance', async () => {
const pill = pillOf(paddleBench({ data: 'partial', open: 2, review: 1, decided: 3, bans: 1 }));
const label = [
'Obstacle data: Partial',
'2 open',
'1 to check again',
'3 decided',
'1 with a ban',
BANS_INFO,
NOT_CLEARANCE,
].join('\n');
expect(pill.textContent).toBe('3 open');
expect(pill.querySelector('svg.lucide-ban')).not.toBeNull();
expect(pill).toHaveAttribute('aria-label', label);
expect(await tooltipOf(pill)).toBe(label);
});
});
interface Coverage {
id: string;
data: Exclude<PaddleCheckData, 'covered'>;
word: string;
info: string;
}
const COVERAGE: Coverage[] = [
{
id: 'PCB-011',
data: 'partial',
word: 'Partial',
info: `Obstacle data covers only part of the route (as of ${AS_OF}). Check the rest yourself.`,
},
{
id: 'PCB-012',
data: 'none',
word: 'No data',
info: 'No obstacle data for this area. Check weirs, locks and portages here yourself.',
},
{
id: 'PCB-013',
data: 'limited',
word: 'Cut short',
info: 'TREK shows only part of the objects here; the area holds more. Check the water yourself.',
},
{
id: 'PCB-014',
data: 'unavailable',
word: 'Unavailable',
info: 'The obstacle data could not be loaded. Check the water yourself.',
},
];
describe('PaddleCheckDataLine', () => {
it('PCB-010: covered data shows no coverage badge, the date OSM is current to and when TREK fetched it', async () => {
render(<PaddleCheckDataLine check={paddleBench({ data_as_of: DATA_AS_OF, read_at: READ_AT })} />);
expect(screen.queryByTestId('paddle-check-coverage')).not.toBeInTheDocument();
const asOf = screen.getByTestId('paddle-check-as-of');
expect(within(asOf).getByText('OSM as of')).toBeInTheDocument();
expect(within(asOf).getByText(AS_OF)).toBeInTheDocument();
const read = screen.getByTestId('paddle-check-read');
expect(within(read).getByText('Fetched')).toBeInTheDocument();
expect(within(read).getByText(READ)).toBeInTheDocument();
// Both sit in the one wrapping line.
expect(screen.getByTestId('paddle-check-data')).toContainElement(asOf);
expect(screen.getByTestId('paddle-check-data')).toContainElement(read);
expect(await tooltipOf(asOf)).toBe(`OpenStreetMap data as of ${AS_OF}, not a check on site.`);
});
it.each(COVERAGE)(
'$id: $data data names its coverage and says to check the water yourself',
async ({ data, word, info }) => {
render(<PaddleCheckDataLine check={paddleBench({ data, data_as_of: DATA_AS_OF })} />);
const coverage = screen.getByTestId('paddle-check-coverage');
expect(within(coverage).getByText('Obstacle data')).toBeInTheDocument();
expect(within(coverage).getByText(word)).toBeInTheDocument();
expect(await tooltipOf(coverage)).toBe(info);
}
);
it('PCB-015: an unknown date reads as unknown, and data never fetched shows no fetch date', async () => {
render(<PaddleCheckDataLine check={paddleBench({ data: 'partial', data_as_of: null, read_at: null })} />);
const asOf = screen.getByTestId('paddle-check-as-of');
expect(within(asOf).getByText('unknown')).toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-read')).not.toBeInTheDocument();
expect(await tooltipOf(asOf)).toBe('OpenStreetMap data of an unknown date, not a check on site.');
expect(await tooltipOf(screen.getByTestId('paddle-check-coverage'))).toBe(
'Obstacle data covers only part of the route (as of an unknown date). Check the rest yourself.'
);
});
});
describe('ChecklistGlyph', () => {
it('PCB-016: an obstacle on the route sits in a solid 24 px ring of its kind colour', () => {
render(<ChecklistGlyph kind="weir" />);
const ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.width).toBe('24px');
expect(ring.style.height).toBe('24px');
expect(ring.style.color).toBe('var(--map-paddle-obstacle)');
expect(ring.style.borderColor).toBe('var(--map-paddle-obstacle)');
expect(ring).toHaveClass('border-solid');
expect(ring).not.toHaveClass('border-dashed');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'weir');
});
it('PCB-017: an object only near the route gets a dashed ring at the size asked for; a lock gate shows the lock', () => {
render(<ChecklistGlyph kind="lock_gate" dashed size={20} />);
const ring = screen.getByTestId('paddle-check-glyph');
expect(ring).toHaveClass('border-dashed');
expect(ring.style.width).toBe('20px');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock');
});
it('PCB-018: an own portage, an own lock and an access rule take the colour and symbol of their kind', () => {
const { rerender } = render(<ChecklistGlyph kind="portage" />);
let ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.borderColor).toBe('var(--map-portage)');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'portage');
rerender(<ChecklistGlyph kind="lock" />);
ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.borderColor).toBe('var(--map-paddle-obstacle)');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock');
rerender(<ChecklistGlyph kind="access" />);
ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.borderColor).toBe('var(--map-paddle-access)');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'access');
});
});
interface EarlierStatus {
id: string;
status: 'off_route' | 'unknown';
decision: ObstacleDecision;
chosen: string;
word: string;
}
const EARLIER: EarlierStatus[] = [
{
id: 'PCB-027',
status: 'off_route',
decision: 'not_affected',
chosen: 'Not affected',
word: 'No longer on the route',
},
{ id: 'PCB-028', status: 'unknown', decision: 'reroute', chosen: 'Go round', word: 'Not comparable' },
];
describe('ObstacleStatusBadges', () => {
it('PCB-019: an open entry reads as open and nothing more', () => {
render(<ObstacleStatusBadges entry={obstacleEntry()} />);
const badges = screen.getAllByTestId('paddle-check-status');
expect(badges).toHaveLength(1);
expect(badges[0]?.textContent).toBe('Open');
});
it('PCB-020: a decided entry shows the decision alone', () => {
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'decided', decision: decisionOf('not_affected') })} />);
expect(screen.getByTestId('paddle-check-status').textContent).toBe('Not affected');
});
it('PCB-021: an entry counted as decided that carries no decision shows no empty badge', () => {
const { container } = render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'decided', decision: null })} />);
expect(container).toBeEmptyDOMElement();
});
it('PCB-022: a choice whose plan is missing names the choice and what is missing', () => {
const { rerender } = render(
<ObstacleStatusBadges entry={obstacleEntry({ status: 'todo', decision: decisionOf('portage') })} />
);
let badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Carry round')).toBeInTheDocument();
expect(within(badge).getByText('No portage yet')).toBeInTheDocument();
rerender(<ObstacleStatusBadges entry={obstacleEntry({ status: 'todo', decision: decisionOf('reroute') })} />);
badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Go round')).toBeInTheDocument();
expect(within(badge).getByText('Route still passes')).toBeInTheDocument();
});
it('PCB-023: an entry to check again gets one badge for each change, each with the turning arrow', () => {
const entry = obstacleEntry({ status: 'review', changed: ['route', 'source'], decision: decisionOf('pass') });
render(<ObstacleStatusBadges entry={entry} />);
const badges = screen.getAllByTestId('paddle-check-status');
expect(badges.map((badge) => badge.textContent)).toEqual(['Check againRoute changed', 'Check againData changed']);
for (const badge of badges) expect(badge.querySelector('svg.lucide-rotate-ccw')).not.toBeNull();
// The earlier choice is the panel's to show, not the badge's.
expect(screen.queryByText('Paddle through')).not.toBeInTheDocument();
});
it('PCB-024: one change gives one badge', () => {
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'review', changed: ['source'] })} />);
const badges = screen.getAllByTestId('paddle-check-status');
expect(badges).toHaveLength(1);
expect(badges[0]?.textContent).toBe('Check againData changed');
});
it('PCB-025: an entry to check again without a recorded change still says so, with the turning arrow', () => {
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'review', changed: [] })} />);
const badge = screen.getByTestId('paddle-check-status');
expect(badge.textContent).toBe('Check again');
expect(badge.querySelector('svg.lucide-rotate-ccw')).not.toBeNull();
});
it('PCB-026: a decision about an object the data no longer holds says so, and kept it steps back', async () => {
const { rerender } = render(<ObstacleStatusBadges entry={earlierEntry()} />);
let badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Paddle through')).toBeInTheDocument();
expect(within(badge).getByText('No longer in the data')).toBeInTheDocument();
expect(badge.closest('.opacity-60')).toBeNull();
// Missing from the data is no proof the obstacle is gone.
expect(await tooltipOf(badge)).toBe(GONE_INFO);
rerender(<ObstacleStatusBadges entry={earlierEntry({ status: 'kept' })} />);
badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Paddle through')).toBeInTheDocument();
expect(within(badge).getByText('No longer in the data')).toBeInTheDocument();
expect(badge.closest('.opacity-60')).not.toBeNull();
});
it.each(EARLIER)(
'$id: an earlier decision now $status names the decision and why it stands apart',
({ status, decision, chosen, word }) => {
render(<ObstacleStatusBadges entry={earlierEntry({ status, decision: decisionOf(decision) })} />);
const badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText(chosen)).toBeInTheDocument();
expect(within(badge).getByText(word)).toBeInTheDocument();
expect(badge.closest('.opacity-60')).toBeNull();
}
);
});
describe('ObstacleAccessBadge', () => {
it('PCB-029: a rule that neither forbids, makes private nor carries a condition shows nothing', () => {
const { container, rerender } = render(<ObstacleAccessBadge access={NO_RULE} />);
expect(container).toBeEmptyDOMElement();
rerender(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'yes', source: 'canoe' }} />);
expect(container).toBeEmptyDOMElement();
});
it('PCB-030: a ban for canoes names the canoe rule and colours its value as a warning', () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'no', source: 'canoe', forbidden: true }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Canoe')).not.toHaveClass('text-warning');
expect(within(badge).getByText('no').parentElement).toHaveClass('text-warning');
});
it('PCB-031: private ground under the general access rule is a warning too', () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'private', source: 'access', private: true }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Access')).toBeInTheDocument();
expect(within(badge).getByText('private').parentElement).toHaveClass('text-warning');
});
it('PCB-032: a value TREK has no words for is shown as written, its condition in the tooltip', async () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'permit', source: 'boat', condition: CONDITION }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Boat')).toBeInTheDocument();
expect(within(badge).getByText('permit').parentElement).toHaveClass('text-content');
expect(within(badge).getByText('permit').parentElement).not.toHaveClass('text-warning');
expect(await tooltipOf(badge)).toBe(`Condition: ${CONDITION}`);
});
it('PCB-033: a condition without a value of its own is shown as written under its own label', () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, condition: CONDITION }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Condition')).toBeInTheDocument();
expect(within(badge).getByText(CONDITION)).toBeInTheDocument();
});
});
@@ -0,0 +1,209 @@
import type {
ObstacleDecision,
ObstacleEntryStatus,
PaddleCheck,
PaddleCheckData,
PaddleObstacleKind,
TourObstacleEntry,
} from '@trek/shared';
import { RotateCcw } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { TourDayRowPill } from '../TourDayRowFacts';
import { FigureBadge } from './FigureBadge';
import { PaddleGlyph } from './PaddleGlyph';
import { checkDataWord, formatCheckDate, paddleCheckFact } from './paddleCheckModel';
import { OBSTACLE_GLYPH } from './paddleGlyphPaths';
import { KNOWN_ACCESS_VALUES } from './paddleObstacleModel';
import { paddleKindCss } from './paddlePaint';
type Translate = (key: string, params?: Record<string, string | number>) => string;
const ACCESS_KEYS: readonly string[] = KNOWN_ACCESS_VALUES;
/** Why data that falls short of the route asks for a look of one's own, as the obstacle notice says it. */
const COVERAGE_INFO: Record<Exclude<PaddleCheckData, 'covered'>, (t: Translate, date: string) => string> = {
partial: (t, date) => t('tours.paddle.obstacles.partial', { date }),
none: (t) => t('tours.paddle.obstacles.none'),
limited: (t) => t('tours.paddle.obstacles.truncated'),
unavailable: (t) => t('tours.paddle.obstacles.error'),
};
/** How a status reads beside the decision it is about. */
interface StatusLook {
/** The status in words; null where the decision alone says it. */
word: (t: Translate, decision: ObstacleDecision | null) => string | null;
info?: (t: Translate) => string;
/** A decision kept about an object the data no longer holds steps back. */
dimmed?: boolean;
}
const GONE: StatusLook = {
word: (t) => t('tours.paddle.check.status.gone'),
info: (t) => t('tours.paddle.check.goneInfo'),
};
/** Every status but review, which gets a badge for each thing that changed. */
const STATUS_LOOK: Record<Exclude<ObstacleEntryStatus, 'review'>, StatusLook> = {
open: { word: (t) => t('tours.paddle.check.status.open') },
decided: { word: () => null },
// A choice whose plan is missing: no portage drawn yet, or the route still passes.
todo: {
word: (t, decision) =>
t(
decision === 'reroute' ? 'tours.paddle.check.status.reroutePending' : 'tours.paddle.check.status.portageMissing'
),
},
gone: GONE,
kept: { ...GONE, dimmed: true },
off_route: { word: (t) => t('tours.paddle.check.status.offRoute') },
unknown: { word: (t) => t('tours.paddle.check.status.unknown') },
};
/**
* The check bench of a paddle tour as one pill (PAD-09), in the badge the day
* plan gives a tour's facts: the first rule that applies gives its value, and
* its tooltip names the whole bench and that it is no clearance.
*/
export function PaddleCheckPill({ check }: { check: PaddleCheck }) {
const { t } = useTranslation();
const { icon, value, label } = paddleCheckFact(check, t);
return <TourDayRowPill icon={icon} value={value} label={label} testId="plan-tour-paddle-check" />;
}
/**
* How far the obstacle data of a bench reaches and how current it is: a badge
* for data that does not cover the route, the date OpenStreetMap is current
* to, and when TREK fetched it, which is never a check on site.
*/
export function PaddleCheckDataLine({ check }: { check: PaddleCheck }) {
const { t, locale } = useTranslation();
const word = checkDataWord(check.data, t);
const asOf = check.data_as_of ? formatCheckDate(check.data_as_of, locale) : null;
const coverage =
check.data === 'covered' ? null : COVERAGE_INFO[check.data](t, asOf ?? t('tours.paddle.obstacles.dateUnknown'));
return (
<div data-testid="paddle-check-data" className="flex flex-wrap items-center gap-1">
{word && coverage && (
<FigureBadge
testId="paddle-check-coverage"
label={t('tours.paddle.check.dataLabel')}
value={word}
info={coverage}
/>
)}
<FigureBadge
testId="paddle-check-as-of"
label={t('tours.paddle.check.sourceDate')}
value={asOf ?? t('tours.paddle.check.unknown')}
info={
asOf ? t('tours.paddle.obstacleCard.source', { date: asOf }) : t('tours.paddle.obstacleCard.sourceUndated')
}
/>
{check.read_at ? (
<FigureBadge
testId="paddle-check-read"
label={t('tours.paddle.check.readDate')}
value={formatCheckDate(check.read_at, locale)}
/>
) : null}
</div>
);
}
/**
* The symbol of a checklist row: the kind's glyph in a ring of its colour,
* the ring dashed for an object only near the route. The paddler's own
* portages and locks take the portage and lock kinds.
*/
export function ChecklistGlyph({
kind,
dashed = false,
size = 24,
}: {
kind: PaddleObstacleKind;
/** An object only near the route: how it stands to it is uncertain. */
dashed?: boolean;
size?: number;
}) {
const color = paddleKindCss(kind);
return (
<span
data-testid="paddle-check-glyph"
aria-hidden="true"
className={`grid flex-none place-items-center rounded-full border-2 bg-surface-card ${dashed ? 'border-dashed' : 'border-solid'}`}
style={{ width: size, height: size, color, borderColor: color }}
>
<PaddleGlyph kind={OBSTACLE_GLYPH[kind]} size={Math.round(size * 0.55)} strokeWidth={2.3} />
</span>
);
}
/** An entry to check again: one badge for each thing that changed since the decision. */
function ReviewBadges({ changed }: { changed: TourObstacleEntry['changed'] }) {
const { t } = useTranslation();
const review = t('tours.paddle.check.status.review');
const icon = <RotateCcw size={10} strokeWidth={2.4} aria-hidden="true" className="flex-none" />;
if (changed.length === 0) return <FigureBadge testId="paddle-check-status" value={review} icon={icon} />;
return (
<>
{changed.map((reason) => (
<FigureBadge
key={reason}
testId="paddle-check-status"
label={review}
value={t(`tours.paddle.check.changed.${reason}`)}
icon={icon}
/>
))}
</>
);
}
/**
* Where a checklist entry stands, as its row and its decision panel show it:
* the decision with what still stands in its way, a badge for each change an
* entry to check again has seen, and a decision kept about an object the data
* no longer holds dimmed. The badges come without a box of their own, so they
* flow on in the line they are put into.
*/
export function ObstacleStatusBadges({ entry }: { entry: TourObstacleEntry }) {
const { t } = useTranslation();
if (entry.status === 'review') return <ReviewBadges changed={entry.changed} />;
const look = STATUS_LOOK[entry.status];
const decision = entry.decision?.decision ?? null;
const chosen = decision ? t(`tours.paddle.check.decision.${decision}`) : undefined;
const word = look.word(t, decision);
const value = word ?? chosen;
if (!value) return null;
const badge = (
<FigureBadge testId="paddle-check-status" label={word ? chosen : undefined} value={value} info={look.info?.(t)} />
);
return look.dimmed ? <span className="inline-flex opacity-60">{badge}</span> : badge;
}
/** A rule's value in the user's words where TREK knows it, as the tags write it where it does not. */
function accessValueOf(value: string, t: Translate): string {
return ACCESS_KEYS.includes(value) ? t(`tours.paddle.obstacleCard.accessValue.${value}`) : value;
}
/**
* The paddling rule an obstacle's tags give, on its row only when it forbids,
* makes the ground private or carries a condition. A rule that forbids or
* makes it private takes the warning tone, as on the map card; a condition
* without a value of its own is shown as written.
*/
export function ObstacleAccessBadge({ access }: { access: TourObstacleEntry['access'] }) {
const { t } = useTranslation();
if (!access.forbidden && !access.private && !access.condition) return null;
const condition = t('tours.paddle.obstacleCard.condition');
const { value } = access;
return (
<FigureBadge
testId="paddle-check-access"
label={value === null ? condition : t(`tours.paddle.obstacleCard.access.${access.source ?? 'access'}`)}
value={value === null ? (access.condition ?? '') : accessValueOf(value, t)}
info={access.condition ? `${condition}: ${access.condition}` : undefined}
tone={access.forbidden || access.private ? 'warning' : 'neutral'}
/>
);
}
@@ -0,0 +1,445 @@
// FE-PADDLE-CHECKCARD-001 to FE-PADDLE-CHECKCARD-017: the checklist card of a paddle tour (PAD-06) in the planner
// rail, its state notices, its rows in route order and the fold of earlier decisions.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PaddleAccess, PaddleCheck, TourObstacleEntry } from '@trek/shared';
import { buildSettings } from '../../../../tests/helpers/factories';
import {
earlierEntry,
obstacleCheck,
obstacleEntry,
ownPortage,
paddleBench,
} from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { PaddleCheckCard } from './PaddleCheckCard';
import { formatCheckDate } from './paddleCheckModel';
import type { PaddleChecklist } from './usePaddleChecklist';
// The badges, the entry dialog and the data notice have suites of their own; here they show what the card hands them.
vi.mock('./PaddleCheckBadges', () => ({
ChecklistGlyph: ({ kind, dashed = false }: { kind: string; dashed?: boolean; size?: number }) => (
<span data-testid="checklist-glyph" data-kind={kind} data-dashed={String(dashed)} />
),
ObstacleStatusBadges: ({ entry }: { entry: TourObstacleEntry }) => <span>status {entry.status}</span>,
ObstacleAccessBadge: ({ access }: { access: PaddleAccess }) =>
access.forbidden || access.private ? <span>access {access.value}</span> : null,
PaddleCheckPill: ({ check }: { check: PaddleCheck }) => (
<span data-testid="paddle-check-pill">{check.open} open</span>
),
PaddleCheckDataLine: ({ check }: { check: PaddleCheck }) => <span data-testid="paddle-check-data">{check.data}</span>,
}));
vi.mock('./ObstacleEntryDialog', () => ({
ObstacleEntryDialog: ({ checklist }: { checklist: PaddleChecklist }) => (
<div role="dialog" aria-label="Obstacle entry">
{checklist.dialogKey}
</div>
),
}));
vi.mock('./PaddleObstacleNotice', () => ({ PaddleObstacleNotice: () => <p>What TREK knows about the water</p> }));
/** The line the data notice stub shows. */
const OBSTACLE_NOTICE = 'What TREK knows about the water';
/** The words as shared/src/i18n/en/tours.ts has them. */
const SAYS = {
title: 'Checklist',
saveFirst: 'Save the tour to check it.',
loading: 'Loading the obstacles along the route …',
offlineEmpty: 'The checklist loads once you are online.',
error: 'The obstacle data could not be loaded. Check the water yourself.',
unsaved: 'Unsaved changes. The list shows the saved route.',
none: 'No obstacle data for this area. Check weirs, locks and portages here yourself.',
dateUnknown: 'an unknown date',
notClearance: 'A checklist is no safety or legal clearance.',
earlier: 'Earlier decisions',
own: 'Own',
ownInfo: 'Your own plan, not a fact from a data source.',
near: 'Near the route',
supLock: 'SUP: check a portage',
privateWay: 'Private way',
carry: 'Carrying',
bypassed: 'Carried round, the portage counts',
};
const staleLine = (time: string): string => `As of ${time}. Updates once online.`;
const coveredEmptyLine = (date: string): string =>
`No known obstacles along the route (OpenStreetMap as of ${date}). Check the water yourself all the same.`;
/** A date as the card writes it, with the spaces the DOM queries read. */
const dateOf = (value: string): string => formatCheckDate(value, 'en').replace(/\s/g, ' ');
/** The weir of the fixtures: on the route at 1.2 km, open. */
const WEIR = obstacleEntry();
/** A weir further on that OpenStreetMap closes to canoes. */
const BANNED_WEIR = obstacleEntry({
key: 'way/3',
refs: ['way/3'],
name: 'Wehr Kallmünz',
along_m: 2500,
access: { value: 'no', source: 'canoe', condition: null, forbidden: true, private: false },
});
/** An unnamed lock beside the route, decided already, where SUP paddlers are advised to carry round. */
const NEAR_LOCK = obstacleEntry({
key: 'lock:node/2',
refs: ['node/2', 'node/3'],
kind: 'lock',
name: null,
relation: 'near',
along_m: 3400,
distance_m: 40,
advice: 'sup_lock_portage',
status: 'decided',
decision: { decision: 'portage', note: null, decided_at: '2026-10-10T09:00:00.000Z', decided_by: 1 },
});
/** The paddler's own portage round the first weir. */
const OWN_PORTAGE = ownPortage({ from_m: 1100, to_m: 1250 });
/** A lock the paddler marked and locks through on a SUP, so the advice to check a portage stands. */
const ADVISED_LOCK = ownPortage({
kind: 'lock',
index: 4,
from_m: 5000,
to_m: 5000,
covers: [],
advice: 'sup_lock_portage',
});
/** A lock the paddler marked that a portage carries round; the server gives it no advice. */
const BYPASSED_LOCK = ownPortage({ kind: 'lock', index: 5, from_m: 5600, to_m: 5600, covers: [], bypassed: true });
/** All of it out of route order, as the server may list it. */
const ROUTE = obstacleCheck({ entries: [NEAR_LOCK, WEIR, BANNED_WEIR], own: [ADVISED_LOCK, OWN_PORTAGE] });
/** The weir, and an earlier decision about a sill the route no longer meets. */
const GONE = earlierEntry({ status: 'off_route' });
const GONE_NAME = 'Alte Sohlschwelle';
const WITH_EARLIER = obstacleCheck({ entries: [WEIR, GONE] });
/** The weir, and a decision about a sill the complete data no longer holds: it counts, so it stays in view. */
const VANISHED = earlierEntry({ key: 'node/8', refs: ['node/8'], name: 'Verschwundenes Wehr' });
/** What usePaddleChecklist hands the card: a ready list matching the saved route, nothing selected, every action a spy. */
function checklistOf(overrides: Partial<PaddleChecklist> = {}): PaddleChecklist {
return {
status: 'ready',
tour: null,
check: obstacleCheck(),
summary: paddleBench(),
stale: false,
dirty: false,
canDecide: true,
canPlanPortage: true,
savingKey: null,
selectedKey: null,
dialogKey: null,
entryForRef: vi.fn(() => null),
select: vi.fn(),
selectOwn: vi.fn(),
closeDialog: vi.fn(),
decide: vi.fn(async () => true),
setNote: vi.fn(async () => true),
clear: vi.fn(async () => true),
keep: vi.fn(async () => true),
nextOf: vi.fn(() => null),
next: vi.fn(),
planPortage: vi.fn(),
memberName: vi.fn(() => null),
...overrides,
};
}
const renderCard = (checklist: PaddleChecklist) => render(<PaddleCheckCard checklist={checklist} />);
/** The rows along the route, in the order the card lists them. */
function rowButtons(): HTMLElement[] {
return within(screen.getByTestId('paddle-check-rows')).getAllByRole('button');
}
/** The row along the route whose name starts as given. */
function rowNamed(name: RegExp): HTMLElement {
return within(screen.getByTestId('paddle-check-rows')).getByRole('button', { name });
}
/** The element a badge's tooltip hangs on. */
function parentOf(element: HTMLElement): HTMLElement {
const parent = element.parentElement;
if (!parent) throw new Error('no parent around the badge');
return parent;
}
beforeEach(() => {
vi.clearAllMocks();
// No distance unit chosen: the card reads metric, in English.
seedStore(useSettingsStore, { settings: buildSettings() });
});
describe('PaddleCheckCard', () => {
it('FE-PADDLE-CHECKCARD-001: renders nothing while the checklist is off', () => {
const { container } = renderCard(checklistOf({ status: 'off', check: null, summary: null }));
expect(container).toBeEmptyDOMElement();
});
it('FE-PADDLE-CHECKCARD-002: a new paddle tour asks to be saved first and says what is known about the water', () => {
renderCard(checklistOf({ status: 'new', check: null, summary: null, dirty: true }));
const card = screen.getByRole('region', { name: SAYS.title });
const saveFirst = within(card).getByText(SAYS.saveFirst);
const notice = within(card).getByText(OBSTACLE_NOTICE);
// The request to save comes first, the data notice below it; a new draft is unsaved by nature and says no more.
expect(saveFirst.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(within(card).queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(within(card).queryByTestId('paddle-check-pill')).not.toBeInTheDocument();
expect(within(card).queryByTestId('paddle-check-data')).not.toBeInTheDocument();
expect(within(card).queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-003: while the list loads it says so as a status with a spinner, the bench already in the head', () => {
renderCard(checklistOf({ status: 'loading', check: null, dirty: true }));
const status = screen.getByRole('status');
expect(status).toHaveTextContent(SAYS.loading);
expect(status.querySelector('svg.animate-spin')).not.toBeNull();
// Loading speaks before unsaved changes.
expect(screen.queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(screen.queryByText(OBSTACLE_NOTICE)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-pill')).toHaveTextContent('1 open');
expect(screen.getByTestId('paddle-check-data')).toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-004: offline without a cached list it says the list loads once online', () => {
renderCard(checklistOf({ status: 'offline-empty', check: null }));
expect(screen.getByText(SAYS.offlineEmpty)).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-005: a list that could not be read says the obstacle data could not be loaded', () => {
renderCard(checklistOf({ status: 'error', check: null }));
expect(screen.getByText(SAYS.error)).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-006: unsaved route changes speak before the cache and dim the rows without hiding them', () => {
renderCard(checklistOf({ check: ROUTE, dirty: true, stale: true }));
expect(screen.getByText(SAYS.unsaved)).toBeInTheDocument();
expect(screen.queryByText(/Updates once online/)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-list')).toHaveClass('opacity-60');
expect(rowButtons()).toHaveLength(5);
});
it('FE-PADDLE-CHECKCARD-007: a list from the cache says when it was worked out and dims the rows', () => {
renderCard(checklistOf({ check: ROUTE, stale: true }));
expect(screen.getByText(staleLine(dateOf(ROUTE.evaluated_at)))).toBeInTheDocument();
expect(screen.queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-list')).toHaveClass('opacity-60');
expect(rowButtons()).toHaveLength(5);
});
it('FE-PADDLE-CHECKCARD-008: lists obstacles and own marks in route order, each with its place and where it stands', () => {
renderCard(checklistOf({ check: ROUTE }));
const card = screen.getByRole('region', { name: SAYS.title });
expect(within(card).getByTestId('paddle-check-pill')).toHaveTextContent('1 open');
expect(within(card).getByTestId('paddle-check-data')).toHaveTextContent('covered');
// A ready list that matches the saved route has nothing to say above it and is not dimmed.
expect(within(card).queryByRole('status')).not.toBeInTheDocument();
expect(within(card).queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-list')).not.toHaveClass('opacity-60');
const rows = rowButtons();
expect(rows.map((row) => row.textContent)).toEqual([
expect.stringMatching(/^Portage1\.1 km/),
expect.stringMatching(/^Wehr Regendorf1\.2 km/),
expect.stringMatching(/^Wehr Kallmünz2\.5 km/),
expect.stringMatching(/^Lock3\.4 km/),
expect.stringMatching(/^Lock5 km/),
]);
const [, weir, banned, lock] = rows;
expect(within(weir).getByText('status open')).toBeInTheDocument();
expect(within(weir).getByTestId('checklist-glyph')).toHaveAttribute('data-dashed', 'false');
expect(within(weir).queryByText(SAYS.near)).not.toBeInTheDocument();
expect(within(weir).queryByText(/^access/)).not.toBeInTheDocument();
expect(within(banned).getByText('access no')).toBeInTheDocument();
// An unnamed lock goes by its kind: near the route on a dashed ring, its decision and the SUP advice.
expect(within(lock).getByTestId('checklist-glyph')).toHaveAttribute('data-kind', 'lock');
expect(within(lock).getByTestId('checklist-glyph')).toHaveAttribute('data-dashed', 'true');
expect(within(lock).getByText(SAYS.near)).toBeInTheDocument();
expect(within(lock).getByText('status decided')).toBeInTheDocument();
expect(within(lock).getByText(SAYS.supLock)).toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-009: an own portage reads as the own plan, how far it carries, what it carries round', async () => {
renderCard(
checklistOf({ check: obstacleCheck({ own: [ownPortage({ from_m: 1100, to_m: 1250, conflict: 'private' })] }) })
);
const row = rowNamed(/^Portage/);
expect(
within(row)
.getAllByTestId('checklist-glyph')
.map((glyph) => glyph.dataset.kind)
).toEqual(['portage', 'weir']);
expect(within(row).getByText(SAYS.carry).nextElementSibling).toHaveTextContent('150 m');
expect(within(row).getByRole('img', { name: 'Weir' })).toBeInTheDocument();
expect(within(row).getByText(SAYS.privateWay)).toBeInTheDocument();
fireEvent.mouseEnter(parentOf(within(row).getByText(SAYS.own)));
expect(await screen.findByRole('tooltip')).toHaveTextContent(SAYS.ownInfo);
});
it('FE-PADDLE-CHECKCARD-010: a forbidden carrying way reads as its access rule, a lock carried round is dimmed', () => {
const forbidden = ownPortage({ from_m: 2000, to_m: 2200, conflict: 'forbidden' });
renderCard(checklistOf({ check: obstacleCheck({ entries: [], own: [BYPASSED_LOCK, ADVISED_LOCK, forbidden] }) }));
const [portage, advised, bypassed] = rowButtons();
expect(portage).toHaveTextContent('access no');
expect(portage).not.toHaveTextContent(SAYS.privateWay);
// A covered obstacle the checklist does not hold gets no chip.
expect(within(portage).queryByRole('img')).not.toBeInTheDocument();
expect(portage).not.toHaveClass('opacity-60');
// A lock passed on a SUP carries the advice; one a portage carries round is dimmed and says why.
expect(within(advised).getByTestId('checklist-glyph')).toHaveAttribute('data-kind', 'lock');
expect(advised).toHaveTextContent(SAYS.own);
expect(advised).toHaveTextContent(SAYS.supLock);
expect(advised).not.toHaveTextContent(SAYS.carry);
expect(advised).not.toHaveClass('opacity-60');
expect(bypassed).toHaveClass('opacity-60');
expect(bypassed).toHaveTextContent(SAYS.own);
expect(bypassed).toHaveTextContent(SAYS.bypassed);
expect(bypassed).not.toHaveTextContent(SAYS.supLock);
});
it('FE-PADDLE-CHECKCARD-011: a row is one button that selects its obstacle or its own mark', () => {
const select = vi.fn<PaddleChecklist['select']>();
const selectOwn = vi.fn<PaddleChecklist['selectOwn']>();
renderCard(checklistOf({ check: ROUTE, select, selectOwn }));
for (const row of rowButtons()) expect(row).toHaveAttribute('type', 'button');
// Nothing to press inside a row.
expect(within(rowNamed(/^Wehr Kallmünz/)).queryByRole('button')).not.toBeInTheDocument();
fireEvent.click(rowNamed(/^Wehr Kallmünz/));
expect(select).toHaveBeenCalledWith(BANNED_WEIR);
fireEvent.click(rowNamed(/^Portage/));
expect(selectOwn).toHaveBeenCalledWith(OWN_PORTAGE);
expect(select).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-CHECKCARD-012: the selected entry is the current row, scrolled into view, and the mark follows the selection', () => {
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView);
const { rerender } = renderCard(checklistOf({ check: ROUTE, selectedKey: WEIR.key }));
const weir = rowNamed(/^Wehr Regendorf/);
expect(weir).toHaveAttribute('aria-current', 'true');
expect(weir).toHaveClass('ring-1', 'ring-inset', 'ring-accent', 'bg-surface-hover');
expect(rowButtons().filter((row) => row.hasAttribute('aria-current'))).toEqual([weir]);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
expect(scrollIntoView.mock.contexts[0]).toBe(weir);
rerender(<PaddleCheckCard checklist={checklistOf({ check: ROUTE, selectedKey: BANNED_WEIR.key })} />);
const banned = rowNamed(/^Wehr Kallmünz/);
expect(banned).toHaveAttribute('aria-current', 'true');
expect(weir).not.toHaveAttribute('aria-current');
expect(weir).not.toHaveClass('ring-accent');
expect(scrollIntoView).toHaveBeenCalledTimes(2);
expect(scrollIntoView.mock.contexts[1]).toBe(banned);
});
it('FE-PADDLE-CHECKCARD-013: earlier decisions wait folded under their count, apart from the route, and open on request', () => {
const select = vi.fn<PaddleChecklist['select']>();
renderCard(checklistOf({ check: WITH_EARLIER, select }));
const fold = screen.getByRole('button', { name: SAYS.earlier });
expect(fold).toHaveAttribute('aria-expanded', 'false');
expect(within(screen.getByTestId('paddle-check-earlier')).getByText('1')).toBeInTheDocument();
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
expect(rowButtons()).toHaveLength(1);
fireEvent.click(fold);
expect(fold).toHaveAttribute('aria-expanded', 'true');
const earlier = screen.getByRole('button', { name: /^Alte Sohlschwelle/ });
// No longer on the route, so no place along it.
expect(earlier).not.toHaveTextContent('km');
expect(earlier).toHaveTextContent('status off_route');
fireEvent.click(earlier);
expect(select).toHaveBeenCalledWith(GONE);
});
it('FE-PADDLE-CHECKCARD-014: a selected earlier decision opens the fold by itself and comes into view, until folded again', () => {
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView);
const { rerender } = renderCard(checklistOf({ check: WITH_EARLIER }));
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER, selectedKey: GONE.key })} />);
const fold = screen.getByRole('button', { name: SAYS.earlier });
expect(fold).toHaveAttribute('aria-expanded', 'true');
const row = screen.getByRole('button', { name: /^Alte Sohlschwelle/ });
expect(row).toHaveAttribute('aria-current', 'true');
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
expect(scrollIntoView.mock.contexts).toContain(row);
fireEvent.click(fold);
expect(fold).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-018: a decision about an object the data no longer holds stays among the rows, after the route', () => {
renderCard(checklistOf({ check: obstacleCheck({ entries: [VANISHED, WEIR] }) }));
const rows = rowButtons();
expect(rows).toHaveLength(2);
expect(rows[1]).toHaveTextContent('Verschwundenes Wehr');
expect(screen.queryByRole('button', { name: SAYS.earlier })).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-015: without rows it says nothing is known on covered data, and that there is none where none is', () => {
const empty = obstacleCheck({ entries: [], own: [] });
const bench = paddleBench({ entries: 0, open: 0 });
const { rerender } = renderCard(checklistOf({ check: empty, summary: bench }));
expect(screen.getByText(coveredEmptyLine(dateOf(bench.data_as_of ?? '')))).toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-rows')).not.toBeInTheDocument();
rerender(<PaddleCheckCard checklist={checklistOf({ check: empty, summary: { ...bench, data_as_of: null } })} />);
expect(screen.getByText(coveredEmptyLine(SAYS.dateUnknown))).toBeInTheDocument();
rerender(
<PaddleCheckCard
checklist={checklistOf({ check: empty, summary: { ...bench, data: 'none', data_as_of: null } })}
/>
);
expect(screen.getByText(SAYS.none)).toBeInTheDocument();
expect(screen.queryByText(/No known obstacles/)).not.toBeInTheDocument();
// Partial data says so in its data line; the card adds no sentence of its own.
rerender(<PaddleCheckCard checklist={checklistOf({ check: empty, summary: { ...bench, data: 'partial' } })} />);
expect(screen.queryByText(SAYS.none)).not.toBeInTheDocument();
expect(screen.queryByText(/No known obstacles/)).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-016: mounts the entry dialog only while an entry the map cannot show is open in it', () => {
const { rerender } = renderCard(checklistOf({ check: WITH_EARLIER }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER, dialogKey: GONE.key })} />);
expect(screen.getByRole('dialog', { name: 'Obstacle entry' })).toHaveTextContent(GONE.key);
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER })} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-017: closes on the line that a checklist is no clearance, before and after the tour is saved', () => {
const { rerender } = renderCard(checklistOf({ status: 'new', check: null, summary: null }));
expect(screen.getByTestId('paddle-check-card').lastElementChild).toHaveTextContent(SAYS.notClearance);
rerender(<PaddleCheckCard checklist={checklistOf({ check: ROUTE })} />);
expect(screen.getByTestId('paddle-check-card').lastElementChild).toHaveTextContent(SAYS.notClearance);
});
});
@@ -0,0 +1,387 @@
import type {
PaddleAccess,
PaddleCheck,
PaddleObstacleKind,
TourObstacleCheck,
TourObstacleEntry,
TourOwnEntry,
} from '@trek/shared';
import { AlertTriangle, CloudOff, LifeBuoy, ListChecks, Loader2, PencilLine, Save, WifiOff } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { FoldButton, RailCard, TourNotice } from '../tourParts';
import { FigureBadge } from './FigureBadge';
import { ObstacleEntryDialog } from './ObstacleEntryDialog';
import {
ChecklistGlyph,
ObstacleAccessBadge,
ObstacleStatusBadges,
PaddleCheckDataLine,
PaddleCheckPill,
} from './PaddleCheckBadges';
import { checklistRows, formatCheckDate, type ChecklistRow } from './paddleCheckModel';
import { PaddleObstacleNotice } from './PaddleObstacleNotice';
import type { PaddleChecklist, PaddleChecklistStatus } from './usePaddleChecklist';
type Translate = ReturnType<typeof useTranslation>['t'];
/** What the card says above the rows: the tour's own state, unsaved changes, or an answer from the cache. */
type CheckNotice = Exclude<PaddleChecklistStatus, 'off' | 'ready'> | 'dirty' | 'stale';
const ICON = { size: 13, strokeWidth: 2 } as const;
/** Each state's sentence and icon; while the list loads it is a status with a spinner. */
const NOTICES: Record<CheckNotice, { textKey: string; icon: ReactNode; role?: 'status' }> = {
new: { textKey: 'tours.paddle.check.saveFirst', icon: <Save {...ICON} /> },
loading: {
textKey: 'tours.paddle.obstacles.loading',
icon: <Loader2 {...ICON} className="animate-spin" />,
role: 'status',
},
'offline-empty': { textKey: 'tours.paddle.check.offlineEmpty', icon: <WifiOff {...ICON} /> },
error: { textKey: 'tours.paddle.obstacles.error', icon: <AlertTriangle {...ICON} /> },
dirty: { textKey: 'tours.paddle.check.unsaved', icon: <PencilLine {...ICON} /> },
stale: { textKey: 'tours.paddle.check.stale', icon: <CloudOff {...ICON} /> },
};
/** A carrying way the data forbids (`access=no` or `portage:access=no`), as the access rule it runs into. */
const FORBIDDEN_WAY: PaddleAccess = { value: 'no', source: 'access', condition: null, forbidden: true, private: false };
const ROW =
'flex w-full items-start gap-2 rounded-[10px] px-1.5 py-1.5 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
const ROW_SELECTED = 'bg-surface-hover ring-1 ring-inset ring-accent';
/** The one notice that applies: the tour's state first, then unsaved route changes, then a list from the cache. */
function noticeOf({ status, dirty, stale }: Pick<PaddleChecklist, 'status' | 'dirty' | 'stale'>): CheckNotice | null {
if (status === 'off') return null;
if (status !== 'ready') return status;
if (dirty) return 'dirty';
return stale ? 'stale' : null;
}
function kindLabel(kind: PaddleObstacleKind, t: Translate): string {
return t(`tours.paddle.obstacle.kind.${kind}`);
}
/** Metres along the route in the user's distance unit. */
function useDistanceLabel(): (metres: number) => string {
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
return (metres) => formatDistance(metres / 1000, unit);
}
function rowKey(row: ChecklistRow, index: number): string {
return row.type === 'obstacle' ? row.entry.key : `own:${row.own.kind}:${row.own.from_m}:${index}`;
}
/** The sentence the checklist's state calls for. */
function CheckNoticeLine({ notice, evaluatedAt }: { notice: CheckNotice; evaluatedAt: string | null }) {
const { t, locale } = useTranslation();
const { textKey, icon, role } = NOTICES[notice];
const time = evaluatedAt ? formatCheckDate(evaluatedAt, locale) : '';
return (
<TourNotice role={role} icon={icon}>
{t(textKey, { time })}
</TourNotice>
);
}
/** A row of the checklist: one full width button with the glyph, the title and a line of badges that wraps between them. */
function CheckRowButton({
selected = false,
dimmed = false,
onClick,
glyph,
title,
children,
}: {
selected?: boolean;
dimmed?: boolean;
onClick: () => void;
glyph: ReactNode;
title: string;
children: ReactNode;
}) {
const ref = useRef<HTMLButtonElement>(null);
// An entry picked on the map comes into view in the rail.
useEffect(() => {
if (selected) ref.current?.scrollIntoView?.({ block: 'nearest' });
}, [selected]);
return (
<button
ref={ref}
type="button"
onClick={onClick}
aria-current={selected ? 'true' : undefined}
className={`${ROW} ${selected ? ROW_SELECTED : 'hover:bg-surface-hover'} ${dimmed ? 'opacity-60' : ''}`}
>
<span className="flex-none">{glyph}</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-semibold text-content" style={fs(12.5, 'body')} title={title}>
{title}
</span>
<span className="flex flex-wrap items-center gap-1">{children}</span>
</span>
</button>
);
}
/** The SUP advice at a lock: check a portage rather than lock through. */
function SupLockBadge() {
const { t } = useTranslation();
return (
<SoftPill icon={<LifeBuoy size={10} strokeWidth={2.2} className="flex-none" />}>
{t('tours.paddle.check.supLock')}
</SoftPill>
);
}
/** Marks a row as the paddler's own plan, never a fact from a data source. */
function OwnBadge() {
const { t } = useTranslation();
return (
<Tooltip label={t('tours.paddle.check.ownInfo')}>
<span className="inline-flex flex-none">
<SoftPill>{t('tours.paddle.check.own')}</SoftPill>
</span>
</Tooltip>
);
}
/** The obstacles an own portage carries round, each a small glyph its tooltip names. */
function CoveredChips({ keys, entries }: { keys: readonly string[]; entries: readonly TourObstacleEntry[] }) {
const { t } = useTranslation();
const covered = keys
.map((key) => entries.find((entry) => entry.key === key))
.filter((entry): entry is TourObstacleEntry => entry !== undefined);
return (
<>
{covered.map((entry) => {
const label = kindLabel(entry.kind, t);
return (
<Tooltip key={entry.key} label={label}>
<span role="img" aria-label={label} className="inline-flex flex-none">
<ChecklistGlyph kind={entry.kind} size={18} />
</span>
</Tooltip>
);
})}
</>
);
}
/** A carrying way the data records as private or forbidden, in the warning tone the obstacle card gives rules. */
function ConflictBadge({ conflict }: { conflict: NonNullable<TourOwnEntry['conflict']> }) {
const { t } = useTranslation();
if (conflict === 'forbidden') return <ObstacleAccessBadge access={FORBIDDEN_WAY} />;
return <SoftPill tone="warning">{t('tours.paddle.obstacleCard.privateWay')}</SoftPill>;
}
/** One obstacle: what it is, where on the route, and where its decision stands. */
function PaddleCheckRow({
entry,
selected,
onSelect,
}: {
entry: TourObstacleEntry;
selected: boolean;
onSelect: (entry: TourObstacleEntry) => void;
}) {
const { t } = useTranslation();
const distance = useDistanceLabel();
const near = entry.relation === 'near';
return (
<CheckRowButton
selected={selected}
onClick={() => onSelect(entry)}
glyph={<ChecklistGlyph kind={entry.kind} dashed={near} />}
title={entry.name ?? kindLabel(entry.kind, t)}
>
{entry.along_m !== null && <SoftPill className="tabular-nums">{distance(entry.along_m)}</SoftPill>}
{near && <SoftPill>{t('tours.paddle.obstacleCard.near')}</SoftPill>}
<ObstacleStatusBadges entry={entry} />
<ObstacleAccessBadge access={entry.access} />
{entry.advice !== null && <SupLockBadge />}
</CheckRowButton>
);
}
/**
* The paddler's own portage or lock among the obstacles. A portage says how
* far it carries and what it carries round; a lock the portage carries round
* is dimmed and says so.
*/
function OwnCheckRow({
own,
entries,
onSelect,
}: {
own: TourOwnEntry;
entries: readonly TourObstacleEntry[];
onSelect: (own: TourOwnEntry) => void;
}) {
const { t } = useTranslation();
const distance = useDistanceLabel();
const portage = own.kind === 'portage';
const bypassed = !portage && own.bypassed;
return (
<CheckRowButton
dimmed={bypassed}
onClick={() => onSelect(own)}
glyph={<ChecklistGlyph kind={own.kind} />}
title={kindLabel(own.kind, t)}
>
<SoftPill className="tabular-nums">{distance(own.from_m)}</SoftPill>
<OwnBadge />
{portage && <FigureBadge label={t('tours.paddle.time.carry')} value={distance(own.to_m - own.from_m)} />}
{portage && <CoveredChips keys={own.covers} entries={entries} />}
{own.conflict && <ConflictBadge conflict={own.conflict} />}
{bypassed && <SoftPill>{t('tours.paddle.lockBypassed')}</SoftPill>}
{own.advice !== null && <SupLockBadge />}
</CheckRowButton>
);
}
/** One row along the route: an obstacle picks its entry, an own mark its place on the map. */
function RouteRow({
row,
checklist,
entries,
}: {
row: ChecklistRow;
checklist: PaddleChecklist;
entries: readonly TourObstacleEntry[];
}) {
if (row.type === 'own') return <OwnCheckRow own={row.own} entries={entries} onSelect={checklist.selectOwn} />;
return (
<PaddleCheckRow entry={row.entry} selected={row.entry.key === checklist.selectedKey} onSelect={checklist.select} />
);
}
/**
* The decisions the route or the data no longer carries, folded away under
* their count. A selection among them opens the fold, until the paddler folds
* it again for that selection.
*/
function EarlierDecisions({
entries,
selectedKey,
onSelect,
}: {
entries: readonly TourObstacleEntry[];
selectedKey: string | null;
onSelect: (entry: TourObstacleEntry) => void;
}) {
const { t } = useTranslation();
const id = useId();
const [choice, setChoice] = useState<{ open: boolean; key: string | null }>({ open: false, key: null });
const holdsSelected = entries.some((entry) => entry.key === selectedKey);
const expanded = (holdsSelected && choice.key !== selectedKey) || choice.open;
const label = t('tours.paddle.check.earlier');
return (
<div data-testid="paddle-check-earlier" className="border-t border-edge-faint pt-1.5">
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-semibold text-content-secondary" style={fs(11.5, 'body')}>
{label}
</span>
<SoftPill className="tabular-nums">{entries.length}</SoftPill>
<FoldButton
expanded={expanded}
controls={id}
label={label}
onToggle={() => setChoice({ open: !expanded, key: selectedKey })}
/>
</div>
<ul id={id} hidden={!expanded} className="-mx-1.5 mt-1 list-none p-0">
{expanded &&
entries.map((entry) => (
<li key={entry.key}>
<PaddleCheckRow entry={entry} selected={entry.key === selectedKey} onSelect={onSelect} />
</li>
))}
</ul>
</div>
);
}
/** A route without rows: nothing known where the data covers it, no data where there is none; else the data line speaks. */
function EmptyLine({ summary }: { summary: PaddleCheck | null }) {
const { t, locale } = useTranslation();
let text: string | null = null;
if (summary?.data === 'none') text = t('tours.paddle.obstacles.none');
else if (summary?.data === 'covered') {
const date = summary.data_as_of
? formatCheckDate(summary.data_as_of, locale)
: t('tours.paddle.obstacles.dateUnknown');
text = t('tours.paddle.obstacles.coveredEmpty', { date });
}
if (!text) return null;
return (
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{text}
</p>
);
}
/** The rows along the route, then the earlier decisions; dimmed, never hidden, while they may not match the route. */
function CheckList({ checklist, check }: { checklist: PaddleChecklist; check: TourObstacleCheck }) {
const { rows, earlier } = useMemo(() => checklistRows(check), [check]);
// Dimmed while it may not match the route, and while the list it came from reads again.
const dimmed = checklist.dirty || checklist.stale || checklist.status === 'loading';
return (
<div data-testid="paddle-check-list" className={`flex flex-col gap-1.5 ${dimmed ? 'opacity-60' : ''}`}>
{rows.length > 0 ? (
<ul data-testid="paddle-check-rows" className="-mx-1.5 flex list-none flex-col gap-0.5 p-0">
{rows.map((row, index) => (
<li key={rowKey(row, index)}>
<RouteRow row={row} checklist={checklist} entries={check.entries} />
</li>
))}
</ul>
) : (
<EmptyLine summary={checklist.summary} />
)}
{earlier.length > 0 && (
<EarlierDecisions entries={earlier} selectedKey={checklist.selectedKey} onSelect={checklist.select} />
)}
</div>
);
}
/**
* PAD-06 in the planner rail: the obstacle checklist of the open paddle tour.
* The obstacles and the paddler's own portages and locks stand in route
* order, each a row that picks it on the map. One line says what state the
* list is in; while the route has unsaved changes or the list comes from the
* cache, the rows stay readable but dimmed. The card closes on the line that
* a checklist is no safety or legal clearance, and opens an entry the map
* cannot show in a dialog of its own.
*/
export function PaddleCheckCard({ checklist }: { checklist: PaddleChecklist }) {
const { t } = useTranslation();
if (checklist.status === 'off') return null;
const notice = noticeOf(checklist);
const { summary, check } = checklist;
return (
<RailCard
icon={<ListChecks size={14} strokeWidth={2} />}
title={t('tours.paddle.check.title')}
aside={summary ? <PaddleCheckPill check={summary} /> : null}
>
<div data-testid="paddle-check-card" className="flex flex-col gap-2.5">
{notice && <CheckNoticeLine notice={notice} evaluatedAt={check?.evaluated_at ?? null} />}
{notice === 'new' && <PaddleObstacleNotice />}
{summary && <PaddleCheckDataLine check={summary} />}
{check && <CheckList checklist={checklist} check={check} />}
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.check.notClearance')}
</p>
</div>
{checklist.dialogKey !== null && <ObstacleEntryDialog checklist={checklist} />}
</RailCard>
);
}
@@ -0,0 +1,75 @@
// PCS-001 to PCS-004: the checklist section in a paddle tour's detail (PAD-09): the bench and how current its data
// is in one box, then the safety note, which stays when the tour has no bench yet.
import { describe, expect, it } from 'vitest';
import type { TourListItem } from '@trek/shared';
import { paddleBench } from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { PaddleCheckSection } from './PaddleCheckSection';
const SAFETY = 'Paddling has risks. Check water, rules and weather yourself.';
/** A planned paddle tour as the list carries it, without a bench. */
const TOUR: TourListItem = {
place_id: 21,
name: 'Naab paddle',
tour_type: 'kayak',
distance: 12.4,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: null,
planned: false,
caution: false,
boat_type: 'canoe',
};
interface Benchless {
id: string;
what: string;
tour: TourListItem;
}
const BENCHLESS: Benchless[] = [
{ id: 'PCS-002', what: 'no bench yet', tour: { ...TOUR, paddle_check: null } },
{ id: 'PCS-003', what: 'a list cached before the bench existed', tour: TOUR },
];
describe('PaddleCheckSection', () => {
it('PCS-001: a tour with a bench shows its pill and its data in one box, the safety note below it', () => {
render(<PaddleCheckSection tour={{ ...TOUR, paddle_check: paddleBench({ data: 'partial', open: 2 }) }} />);
expect(screen.getByText('Checklist')).toBeInTheDocument();
const summary = screen.getByTestId('paddle-check-summary');
expect(within(summary).getByTestId('plan-tour-paddle-check').textContent).toBe('2 open');
expect(within(summary).getByTestId('paddle-check-coverage')).toHaveTextContent('Partial');
expect(within(summary).getByTestId('paddle-check-as-of')).toHaveTextContent('OSM as of');
expect(within(summary).getByTestId('paddle-check-read')).toHaveTextContent('Fetched');
const row = screen.getByRole('button', { name: SAFETY });
expect(summary).not.toContainElement(row);
expect(summary.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it.each(BENCHLESS)('$id: a tour with $what shows the safety note alone', ({ tour }) => {
render(<PaddleCheckSection tour={tour} />);
expect(screen.getByRole('button', { name: SAFETY })).toBeInTheDocument();
expect(screen.queryByText('Checklist')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-summary')).not.toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-data')).not.toBeInTheDocument();
});
it('PCS-004: the safety note of the section opens the full note', () => {
render(<PaddleCheckSection tour={{ ...TOUR, paddle_check: paddleBench() }} />);
fireEvent.click(screen.getByRole('button', { name: SAFETY }));
expect(screen.getByRole('dialog', { name: 'Safety on the water' })).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import type { TourListItem } from '@trek/shared';
import { useTranslation } from '../../../i18n';
import { BOX } from '../../Planner/bookings/bookingParts';
import { DialogSection } from '../../shared/DialogShell';
import { PaddleCheckDataLine, PaddleCheckPill } from './PaddleCheckBadges';
import { PaddleSafetyRow } from './PaddleSafety';
/**
* The checklist of a paddle tour in its detail (PAD-09): the check bench as
* the day plan shows it and how current its data is, then the safety note.
* A tour without a bench yet shows the safety note alone.
*/
export function PaddleCheckSection({ tour }: { tour: TourListItem }) {
const { t } = useTranslation();
const check = tour.paddle_check ?? null;
if (!check) {
return (
<div className="flex-none">
<PaddleSafetyRow />
</div>
);
}
return (
<DialogSection className="flex-none" label={t('tours.paddle.check.title')}>
<div className="flex flex-col gap-2">
<div data-testid="paddle-check-summary" className={`${BOX} flex flex-col gap-2 px-3 py-2.5`}>
<div className="flex flex-wrap items-center gap-1">
<PaddleCheckPill check={check} />
</div>
<PaddleCheckDataLine check={check} />
</div>
<PaddleSafetyRow />
</div>
</DialogSection>
);
}
@@ -0,0 +1,48 @@
import type { CSSProperties, SVGProps } from 'react';
import { PADDLE_GLYPH_PATHS, type PaddleGlyphKind } from './paddleGlyphPaths';
/**
* The paddler's own symbols, drawn on lucide's grid (24, stroke 2, round
* caps) so they sit beside its icons without a seam: a lock as a chamber
* between its two mitre gates, a portage as footsteps (lucide's own), a weir
* as water falling over a ledge, a dam as a wall holding water back, rapids
* as broken water, getting in and out as an arrow into or out of the water.
* The waypoint list, the map and the checklist all use them.
*/
export function PaddleGlyph({
kind,
size = 16,
strokeWidth = 2,
...rest
}: { kind: PaddleGlyphKind; size?: number; strokeWidth?: number } & Omit<SVGProps<SVGSVGElement>, 'children'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
data-paddle-glyph={kind}
{...rest}
>
{PADDLE_GLYPH_PATHS[kind].map((d) => (
<path key={d} d={d} />
))}
</svg>
);
}
/** The portage glyph as an icon component, for menus that take one. */
export function PortageIcon(props: { size?: number; style?: CSSProperties }) {
return <PaddleGlyph kind="portage" {...props} />;
}
/** The lock glyph as an icon component, for menus that take one. */
export function LockIcon(props: { size?: number; style?: CSSProperties }) {
return <PaddleGlyph kind="lock" {...props} />;
}
@@ -0,0 +1,315 @@
/**
* FE-PADDLE-LEAFLET-001 to FE-PADDLE-LEAFLET-006: the paddle obstacles on the
* Leaflet renderer.
*
* Leaflet itself is not under test: the mock reflects each polyline's,
* marker's and tooltip's props back as data attributes, so the assertions read
* what is drawn, in which order and with what options. Pinned: the objects only
* in the area wait for PADDLE_AREA_MIN_ZOOM unless one is picked, every line
* sits on a white casing that takes no clicks, a line only near the route is
* dashed, the markers grow and rise by how they stand to the route, and a click
* on a marker or a line picks the object without reaching the map underneath.
*/
import L from 'leaflet';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PaddleFeature, PaddleGeometry, PaddleObstacleKind, RelatedObstacle } from '@trek/shared';
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PaddleLayerLeaflet } from './PaddleLayerLeaflet';
import {
PADDLE_AREA_MIN_ZOOM,
paddleMapItems,
paddleMarkerHtml,
type PaddleMapItem,
type PaddleMapLayer,
} from './paddleMapLayer';
import type { PaddlePaint } from './paddlePaint';
/** Test colours standing in for the theme tokens (styles/paddle.css), distinct so each can be told apart. */
const PAINT: PaddlePaint = {
obstacle: '#334155',
access: '#4338ca',
landing: '#0f766e',
portage: '#9a6233',
disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)',
};
const zoom = { value: 12 };
const zoomEnd: { handler: (() => void) | null } = { handler: null };
/** What Leaflet hands a click handler; stopPropagation marks its original event. */
const leafletClick = { originalEvent: new MouseEvent('click') };
type Handlers = Record<string, ((event: typeof leafletClick) => void) | undefined>;
vi.mock('react-leaflet', () => ({
Polyline: ({
positions,
pathOptions,
interactive,
bubblingMouseEvents,
eventHandlers,
}: {
positions: [number, number][];
pathOptions: { color: string; weight: number; opacity: number; dashArray?: string; lineCap?: string };
interactive?: boolean;
bubblingMouseEvents?: boolean;
eventHandlers?: Handlers;
}) => (
<div
data-testid="polyline"
data-positions={JSON.stringify(positions)}
data-color={pathOptions.color}
data-weight={pathOptions.weight}
data-opacity={pathOptions.opacity}
data-dash={pathOptions.dashArray ?? ''}
data-cap={pathOptions.lineCap ?? ''}
data-interactive={interactive === false ? 'false' : 'true'}
data-bubbles={bubblingMouseEvents === false ? 'false' : 'true'}
onClick={() => eventHandlers?.click?.(leafletClick)}
/>
),
Marker: ({
position,
icon,
zIndexOffset,
eventHandlers,
children,
}: {
position: [number, number];
icon: L.DivIcon;
zIndexOffset: number;
eventHandlers: Handlers;
children: ReactNode;
}) => (
<button
type="button"
data-testid="pin"
data-position={JSON.stringify(position)}
data-size={JSON.stringify(icon.options.iconSize)}
data-anchor={JSON.stringify(icon.options.iconAnchor)}
data-class={icon.options.className}
data-html={typeof icon.options.html === 'string' ? icon.options.html : ''}
data-z={zIndexOffset}
onClick={() => eventHandlers.click?.(leafletClick)}
>
{children}
</button>
),
Tooltip: ({
children,
direction,
offset,
opacity,
className,
}: {
children: ReactNode;
direction: string;
offset: [number, number];
opacity: number;
className: string;
}) => (
<span
data-testid="tooltip"
data-direction={direction}
data-offset={JSON.stringify(offset)}
data-opacity={opacity}
data-class={className}
>
{children}
</span>
),
useMap: () => ({ getZoom: () => zoom.value }),
useMapEvents: (handlers: { zoomend?: () => void }) => {
zoomEnd.handler = handlers.zoomend ?? null;
return null;
},
}));
const feature = (
ref: string,
kind: PaddleObstacleKind,
geometry: PaddleGeometry,
name: string | null = null
): PaddleFeature => ({
ref,
kind,
group_key: null,
name,
geometry,
tags: {},
tag_hash: ref,
region_id: 'oberpfalz',
});
const lineString = (...coordinates: [number, number][]): PaddleGeometry => ({ type: 'LineString', coordinates });
const polygon = (...ring: [number, number][]): PaddleGeometry => ({ type: 'Polygon', coordinates: [ring] });
/** In GeoJSON [lng, lat]: a weir the route crosses, a lock beside it, a put-in and an access area further off. */
const WEIR = feature('way/1', 'weir', lineString([12.1, 49.1], [12.1005, 49.1005], [12.101, 49.101]), 'Pielmühle');
const LOCK = feature('way/2', 'lock', lineString([12.11, 49.11], [12.111, 49.111]));
const PUT_IN = feature('node/3', 'put_in', { type: 'Point', coordinates: [12.12, 49.12] }, 'Etterzhausen');
const ACCESS = feature('way/4', 'access', polygon([12.13, 49.13], [12.131, 49.13], [12.131, 49.131], [12.13, 49.13]));
const RELATED: RelatedObstacle[] = [
{ feature: WEIR, relation: 'on_route', distanceM: 0, alongM: 1200 },
{ feature: LOCK, relation: 'near', distanceM: 90, alongM: 2400 },
];
const ITEMS = paddleMapItems([WEIR, LOCK, PUT_IN, ACCESS], RELATED, PAINT);
const ALL_LABELS = ['Weir: Pielmühle', 'Lock', 'Put-in: Etterzhausen', 'Access rule'];
const ROUTE_LABELS = ['Weir: Pielmühle', 'Lock'];
const layerOf = (
items: PaddleMapItem[],
selectedRef: string | null = null,
onSelect: (ref: string) => void = vi.fn()
): PaddleMapLayer => ({ items, selectedRef, selectedKey: null, onSelect });
const lines = () => screen.queryAllByTestId('polyline');
const pins = () => screen.queryAllByTestId('pin');
const labels = () => screen.queryAllByTestId('tooltip').map((tip) => tip.textContent);
const attr = (elements: HTMLElement[], name: string) => elements.map((element) => element.dataset[name]);
const zoomTo = (value: number) => {
zoom.value = value;
act(() => zoomEnd.handler?.());
};
beforeEach(() => {
zoom.value = PADDLE_AREA_MIN_ZOOM - 1;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PaddleLayerLeaflet', () => {
it('FE-PADDLE-LEAFLET-001: draws nothing without a layer, without items, or with only area objects while zoomed out', () => {
const { container, rerender } = render(<PaddleLayerLeaflet />);
expect(container).toBeEmptyDOMElement();
rerender(<PaddleLayerLeaflet layer={layerOf([])} />);
expect(container).toBeEmptyDOMElement();
rerender(<PaddleLayerLeaflet layer={layerOf(paddleMapItems([PUT_IN, ACCESS], [], PAINT))} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-PADDLE-LEAFLET-002: area objects wait for PADDLE_AREA_MIN_ZOOM, join on a zoomend and leave again when zoomed out', () => {
render(<PaddleLayerLeaflet layer={layerOf(ITEMS)} />);
expect(labels()).toEqual(ROUTE_LABELS);
// A casing and a line each for the weir and the lock.
expect(lines()).toHaveLength(4);
zoomTo(PADDLE_AREA_MIN_ZOOM);
expect(labels()).toEqual(ALL_LABELS);
// The put-in is a point and draws no line; the access area adds its outline.
expect(lines()).toHaveLength(6);
zoomTo(PADDLE_AREA_MIN_ZOOM - 1);
expect(labels()).toEqual(ROUTE_LABELS);
expect(lines()).toHaveLength(4);
});
it('FE-PADDLE-LEAFLET-003: a picked area object shows at any zoom, larger and on top, until it is no longer picked', () => {
zoom.value = PADDLE_AREA_MIN_ZOOM - 3;
const { rerender } = render(<PaddleLayerLeaflet layer={layerOf(ITEMS, 'way/4')} />);
expect(labels()).toEqual([...ROUTE_LABELS, 'Access rule']);
expect(lines()).toHaveLength(6);
const pin = screen.getByRole('button', { name: 'Access rule' });
expect(pin.dataset.size).toBe('[36,36]');
expect(pin.dataset.anchor).toBe('[18,18]');
expect(pin.dataset.z).toBe('900');
expect(pin.dataset.html).toBe(paddleMarkerHtml(ITEMS[3], true));
rerender(<PaddleLayerLeaflet layer={layerOf(ITEMS)} />);
expect(labels()).toEqual(ROUTE_LABELS);
expect(lines()).toHaveLength(4);
});
it('FE-PADDLE-LEAFLET-004: markers sit on the point or mid-line, grow and rise by their relation, and name kind and object', () => {
zoom.value = PADDLE_AREA_MIN_ZOOM;
const { rerender } = render(<PaddleLayerLeaflet layer={layerOf(ITEMS)} />);
expect(attr(pins(), 'position')).toEqual([
'[49.1005,12.1005]',
'[49.111,12.111]',
'[49.12,12.12]',
'[49.131,12.131]',
]);
expect(attr(pins(), 'size')).toEqual(['[30,30]', '[26,26]', '[22,22]', '[22,22]']);
expect(attr(pins(), 'anchor')).toEqual(['[15,15]', '[13,13]', '[11,11]', '[11,11]']);
expect(attr(pins(), 'z')).toEqual(['600', '300', '300', '300']);
expect(attr(pins(), 'class')).toEqual(['trek-paddle-pin', 'trek-paddle-pin', 'trek-paddle-pin', 'trek-paddle-pin']);
expect(attr(pins(), 'html')).toEqual(ITEMS.map((item) => paddleMarkerHtml(item, false)));
// The kind, with the object's name when it has one.
expect(labels()).toEqual(ALL_LABELS);
const tip = screen.getAllByTestId('tooltip')[0];
expect(tip.dataset.direction).toBe('top');
expect(tip.dataset.offset).toBe('[0,-12]');
expect(tip.dataset.opacity).toBe('1');
expect(tip.dataset.class).toBe('map-tooltip');
// Picking the weir draws its marker larger and above the rest; the others keep theirs.
rerender(<PaddleLayerLeaflet layer={layerOf(ITEMS, 'way/1')} />);
expect(attr(pins(), 'size')).toEqual(['[36,36]', '[26,26]', '[22,22]', '[22,22]']);
expect(attr(pins(), 'z')).toEqual(['900', '300', '300', '300']);
expect(pins()[0].dataset.html).toBe(paddleMarkerHtml(ITEMS[0], true));
});
it('FE-PADDLE-LEAFLET-005: every line runs on a white casing that takes no clicks, dashed only while near the route', () => {
zoom.value = PADDLE_AREA_MIN_ZOOM;
render(<PaddleLayerLeaflet layer={layerOf(ITEMS)} />);
// The casings of the weir, the lock and the access outline first, then their lines on top.
const casings = lines().slice(0, 3);
const drawn = lines().slice(3);
expect(drawn).toHaveLength(3);
expect(attr(casings, 'color')).toEqual(['#ffffff', '#ffffff', '#ffffff']);
expect(attr(casings, 'weight')).toEqual(['7', '6', '5']);
expect(attr(casings, 'dash')).toEqual(['', '', '']);
expect(attr(casings, 'interactive')).toEqual(['false', 'false', 'false']);
expect(attr(drawn, 'color')).toEqual([PAINT.obstacle, PAINT.obstacle, PAINT.access]);
expect(attr(drawn, 'weight')).toEqual(['4', '3', '2']);
expect(attr(drawn, 'dash')).toEqual(['', '6 5', '']);
expect(attr(drawn, 'cap')).toEqual(['round', 'round', 'round']);
expect(attr(drawn, 'interactive')).toEqual(['true', 'true', 'true']);
expect(attr(drawn, 'bubbles')).toEqual(['false', 'false', 'false']);
// Casing and line fade together, by how the object stands to the route.
expect(attr(casings, 'opacity')).toEqual(['1', '0.85', '0.5']);
expect(attr(drawn, 'opacity')).toEqual(['1', '0.85', '0.5']);
// Both follow the same geometry, turned into [lat, lng].
expect(attr(casings, 'positions')).toEqual(attr(drawn, 'positions'));
expect(attr(drawn, 'positions')).toEqual([
'[[49.1,12.1],[49.1005,12.1005],[49.101,12.101]]',
'[[49.11,12.11],[49.111,12.111]]',
'[[49.13,12.13],[49.13,12.131],[49.131,12.131],[49.13,12.13]]',
]);
});
it('FE-PADDLE-LEAFLET-006: a click on a marker or a line picks the object and goes no further, so no waypoint lands under it', () => {
const stop = vi.spyOn(L.DomEvent, 'stopPropagation');
const onSelect = vi.fn();
render(<PaddleLayerLeaflet layer={layerOf(ITEMS, null, onSelect)} />);
fireEvent.click(screen.getByRole('button', { name: 'Lock' }));
expect(onSelect).toHaveBeenCalledWith('way/2');
expect(stop).toHaveBeenCalledWith(leafletClick);
expect(stop.mock.invocationCallOrder[0]).toBeLessThan(onSelect.mock.invocationCallOrder[0]);
// Two casings, then the weir's line and the lock's.
fireEvent.click(lines()[2]);
expect(onSelect).toHaveBeenLastCalledWith('way/1');
expect(stop).toHaveBeenCalledTimes(2);
// A casing carries no handler of its own.
fireEvent.click(lines()[0]);
expect(onSelect).toHaveBeenCalledTimes(2);
expect(stop).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,121 @@
import L from 'leaflet';
import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../../i18n';
import {
isPaddleItemSelected,
markerSize,
paddleItemsAtZoom,
paddleMarkerHtml,
type PaddleMapItem,
type PaddleMapLayer,
} from './paddleMapLayer';
const LINE_WEIGHT = { on_route: 4, near: 3, area: 2 } as const;
const LINE_OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const;
function iconOf(item: PaddleMapItem, selected: boolean): L.DivIcon {
const size = markerSize(item.relation, selected);
return L.divIcon({
html: paddleMarkerHtml(item, selected),
className: 'trek-paddle-pin',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
/** What a marker's tooltip says: the kind of object and its name, if it has one. */
function useLabel() {
const { t } = useTranslation();
return (item: PaddleMapItem) => {
const kind = t(`tours.paddle.obstacle.kind.${item.feature.kind}`);
return item.feature.name ? `${kind}: ${item.feature.name}` : kind;
};
}
/**
* The paddle obstacles on the Leaflet map (PAD-05B): each line on a white
* casing in its category's colour, dashed while it is only near the route,
* and a round marker with its glyph; the quieter, the less it bears on the
* route, and the objects only in the area wait for a closer zoom. A click
* picks the object and goes no further, so it never also sets a waypoint
* under it.
*/
export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
const label = useLabel();
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
const selectedRef = layer?.selectedRef ?? null;
const selectedKey = layer?.selectedKey ?? null;
const all = layer?.items;
const items = useMemo(() => (all ? paddleItemsAtZoom(all, zoom, selectedRef) : undefined), [all, zoom, selectedRef]);
// Rebuilt only when the items or the selection change, not on every render of the map.
const icons = useMemo(
() =>
new Map(
(items ?? []).map((item) => [
item.feature.ref,
iconOf(item, isPaddleItemSelected(item, selectedRef, selectedKey)),
])
),
[items, selectedRef, selectedKey]
);
if (!layer || !items?.length) return null;
const select = (ref: string) => ({
click: (event: L.LeafletMouseEvent) => {
L.DomEvent.stopPropagation(event);
layer.onSelect(ref);
},
});
return (
<>
{items.map((item) =>
item.line ? (
<Polyline
key={`${item.feature.ref}-casing`}
positions={item.line}
pathOptions={{
color: item.disc,
weight: LINE_WEIGHT[item.relation] + 3,
opacity: LINE_OPACITY[item.relation],
}}
interactive={false}
/>
) : null
)}
{items.map((item) =>
item.line ? (
<Polyline
key={`${item.feature.ref}-line`}
positions={item.line}
bubblingMouseEvents={false}
pathOptions={{
color: item.color,
weight: LINE_WEIGHT[item.relation],
opacity: LINE_OPACITY[item.relation],
dashArray: item.relation === 'near' ? '6 5' : undefined,
lineCap: 'round',
}}
eventHandlers={select(item.feature.ref)}
/>
) : null
)}
{items.map((item) => (
<Marker
key={`${item.feature.ref}-pin`}
position={item.marker}
icon={icons.get(item.feature.ref) as L.DivIcon}
zIndexOffset={
isPaddleItemSelected(item, selectedRef, selectedKey) ? 900 : item.relation === 'on_route' ? 600 : 300
}
eventHandlers={select(item.feature.ref)}
>
<Tooltip direction="top" offset={[0, -12]} opacity={1} className="map-tooltip">
{label(item)}
</Tooltip>
</Marker>
))}
</>
);
}
@@ -0,0 +1,292 @@
// FE-PADDLE-CARD-001 to FE-PADDLE-CARD-015: the card for one paddle object picked on the map or in the checklist.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PaddleFeature, RelatedObstacle } from '@trek/shared';
import { buildSettings } from '../../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import PaddleObstacleCard from './PaddleObstacleCard';
import { paddleObstacleDetail } from './paddleObstacleModel';
type Relation = Omit<RelatedObstacle, 'feature'>;
/** Noon UTC, so the date reads the same in every time zone the suite runs in. */
const AS_OF = '2026-09-01T12:00:00Z';
const ON_ROUTE: Relation = { relation: 'on_route', distanceM: 0, alongM: 1200 };
const NEAR: Relation = { relation: 'near', distanceM: 80, alongM: 640 };
function weir(overrides: Partial<PaddleFeature> = {}): PaddleFeature {
return {
ref: 'way/29256405',
kind: 'weir',
group_key: null,
name: 'Wehr Pielenhofen',
geometry: {
type: 'LineString',
coordinates: [
[11.9561, 49.0567],
[11.9566, 49.0571],
],
},
tags: { waterway: 'weir' },
tag_hash: 'h-29256405',
region_id: 'oberpfalz',
...overrides,
};
}
/** What the card is handed for the picked object, worked out by the model the way the store does it. */
function detailOf(feature: PaddleFeature, related: Relation | null, asOf: string | null = AS_OF) {
const detail = paddleObstacleDetail({
features: [feature],
related: related ? [{ feature, ...related }] : [],
regions: [{ id: 'oberpfalz', name: 'Oberpfalz', source_timestamp: asOf, imported_at: null }],
selectedRef: feature.ref,
});
if (!detail) throw new Error(`${feature.ref} is not picked`);
return detail;
}
/** The weir on the route, carrying only the given tags. */
function withTags(tags: Record<string, string>) {
return detailOf(weir({ tags }), ON_ROUTE);
}
/** The badge a label sits in, which its tooltip hangs on. */
function badgeOf(label: string): HTMLElement {
const badge = screen.getByText(label).parentElement;
if (!badge) throw new Error(`no badge around ${label}`);
return badge;
}
/** The white badge beside a label: it holds the value and carries the tone. */
function valueBeside(label: string): Element {
const value = screen.getByText(label).nextElementSibling;
if (!value) throw new Error(`no value beside ${label}`);
return value;
}
describe('PaddleObstacleCard', () => {
beforeEach(() => {
// No distance unit chosen: the card reads metric.
seedStore(useSettingsStore, { settings: buildSettings() });
});
it('FE-PADDLE-CARD-001: names the object, says what it is and how far along the route it lies', async () => {
const { container } = render(<PaddleObstacleCard detail={detailOf(weir(), ON_ROUTE)} />);
expect(screen.getByRole('heading', { name: 'Wehr Pielenhofen' })).toBeInTheDocument();
expect(screen.getByText('Weir')).toBeInTheDocument();
expect(container.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'weir');
expect(valueBeside('On the route at').textContent).toBe('1.2 km');
expect(screen.getByTestId('paddle-obstacle-relation')).not.toHaveTextContent('Link unclear');
fireEvent.mouseEnter(badgeOf('On the route at'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('How far along the route it lies, from the start');
});
it('FE-PADDLE-CARD-002: an unnamed object takes its kind as title and does not repeat it', () => {
const gate = weir({ ref: 'node/77', kind: 'lock_gate', name: null, tags: { 'seamark:type': 'gate' } });
const { container } = render(<PaddleObstacleCard detail={detailOf(gate, ON_ROUTE)} />);
expect(screen.getByRole('heading', { name: 'Lock gate' })).toHaveAttribute('title', 'Lock gate');
expect(screen.getAllByText('Lock gate')).toHaveLength(1);
// A gate is drawn as part of its lock.
expect(container.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock');
});
it('FE-PADDLE-CARD-003: an object only near the route gives its distance and says the link is unclear', async () => {
render(<PaddleObstacleCard detail={detailOf(weir(), NEAR)} />);
expect(valueBeside('Near the route').textContent).toBe('80 m');
expect(screen.getByText('Link unclear')).toBeInTheDocument();
expect(screen.queryByText('On the route at')).not.toBeInTheDocument();
fireEvent.mouseEnter(badgeOf('Near the route'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'How far from the route it lies. It may be on another arm of the water.'
);
});
it('FE-PADDLE-CARD-004: an object only in the area gives no distance at all', () => {
render(<PaddleObstacleCard detail={detailOf(weir(), null)} />);
const relation = screen.getByTestId('paddle-obstacle-relation');
expect(relation).toHaveTextContent('In the area');
expect(relation).not.toHaveTextContent('On the route at');
expect(relation).not.toHaveTextContent('Near the route');
expect(relation).not.toHaveTextContent('Link unclear');
});
it('FE-PADDLE-CARD-005: reads canoe before boat and words the known values, marking a ban or private water', () => {
const { rerender } = render(<PaddleObstacleCard detail={withTags({ canoe: 'no', boat: 'yes' })} />);
expect(valueBeside('Canoe').textContent).toBe('no');
expect(valueBeside('Canoe')).toHaveClass('text-warning');
expect(screen.queryByText('Boat')).not.toBeInTheDocument();
rerender(<PaddleObstacleCard detail={withTags({ boat: 'permissive' })} />);
expect(valueBeside('Boat').textContent).toBe('tolerated');
expect(valueBeside('Boat')).toHaveClass('text-content');
expect(valueBeside('Boat')).not.toHaveClass('text-warning');
rerender(<PaddleObstacleCard detail={withTags({ access: 'private' })} />);
expect(valueBeside('Access').textContent).toBe('private');
expect(valueBeside('Access')).toHaveClass('text-warning');
// A value the card has no words for is shown as OpenStreetMap has it.
rerender(<PaddleObstacleCard detail={withTags({ canoe: 'portage' })} />);
expect(valueBeside('Canoe').textContent).toBe('portage');
expect(valueBeside('Canoe')).not.toHaveClass('text-warning');
});
it('FE-PADDLE-CARD-006: no paddling rule reads as unknown, never as allowed', () => {
const bare = withTags({});
const { rerender } = render(<PaddleObstacleCard detail={bare} />);
const access = screen.getByTestId('paddle-obstacle-access');
expect(access).toHaveTextContent('No paddling rule known');
expect(access).not.toHaveTextContent('yes');
// A rule that names its tag but carries no value says nothing either.
const unsure = { ...bare.access, source: 'canoe' as const, value: null };
rerender(<PaddleObstacleCard detail={{ ...bare, access: unsure }} />);
expect(screen.getByText('No paddling rule known')).toBeInTheDocument();
expect(screen.queryByText('Canoe')).not.toBeInTheDocument();
});
it('FE-PADDLE-CARD-007: a conditional rule is shown as written, alone or beside its rule', async () => {
const conditional = 'no @ (Nov 01-Mar 31)';
const { rerender } = render(<PaddleObstacleCard detail={withTags({ 'canoe:conditional': conditional })} />);
expect(valueBeside('Condition').textContent).toBe(conditional);
expect(screen.queryByText('No paddling rule known')).not.toBeInTheDocument();
fireEvent.mouseEnter(badgeOf('Condition'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(conditional);
fireEvent.mouseLeave(badgeOf('Condition'));
rerender(<PaddleObstacleCard detail={withTags({ canoe: 'yes', 'canoe:conditional': conditional })} />);
expect(valueBeside('Canoe').textContent).toBe('yes');
expect(valueBeside('Condition').textContent).toBe(conditional);
});
it('FE-PADDLE-CARD-008: names the facts worth knowing, a bare length in the user unit', async () => {
const lock = weir({
ref: 'way/4711',
kind: 'lock',
name: 'Schleuse Regensburg',
tags: {
height: '3',
width: '12',
opening_hours: 'Mo-Su 06:00-22:00',
seasonal: 'summer',
fee: 'yes',
operator: 'WSA Donau MDK',
'seamark:type': 'lock_basin',
},
});
const { rerender } = render(<PaddleObstacleCard detail={detailOf(lock, ON_ROUTE)} />);
expect(valueBeside('Height').textContent).toBe('3 m');
expect(valueBeside('Width').textContent).toBe('12 m');
expect(valueBeside('Hours').textContent).toBe('Mo-Su 06:00-22:00');
expect(valueBeside('Seasonal').textContent).toBe('summer');
expect(valueBeside('Fee').textContent).toBe('yes');
expect(valueBeside('Operator').textContent).toBe('WSA Donau MDK');
// A tag the card has no label for stays behind.
expect(screen.queryByText('lock_basin')).not.toBeInTheDocument();
// The tooltip keeps the tag as OpenStreetMap holds it.
fireEvent.mouseEnter(badgeOf('Width'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(/^12$/);
fireEvent.mouseLeave(badgeOf('Width'));
const rapids = weir({
kind: 'rapids',
name: null,
tags: { 'whitewater:section_grade': 'III', height: '1.2 m' },
});
rerender(<PaddleObstacleCard detail={detailOf(rapids, NEAR)} />);
expect(valueBeside('Grade').textContent).toBe('III');
// A length with a unit of its own is not read as metres.
expect(valueBeside('Height').textContent).toBe('1.2 m');
});
it('FE-PADDLE-CARD-009: a portage recorded as private warns that it is a private way', () => {
const portage = weir({ ref: 'way/815', kind: 'portage', name: null, tags: { 'portage:access': 'private' } });
const { rerender } = render(<PaddleObstacleCard detail={detailOf(portage, NEAR)} />);
expect(screen.getByRole('heading', { name: 'Portage' })).toBeInTheDocument();
expect(screen.getByText('Private way')).toHaveClass('text-warning');
rerender(<PaddleObstacleCard detail={detailOf({ ...portage, tags: { highway: 'path' } }, NEAR)} />);
expect(screen.queryByText('Private way')).not.toBeInTheDocument();
});
it('FE-PADDLE-CARD-010: shows the description OpenStreetMap holds, or else its note, as written', () => {
const description = 'Fish pass on the left bank, the boat chute is closed.';
const { rerender } = render(<PaddleObstacleCard detail={withTags({ description })} />);
expect(screen.getByText(description)).toHaveAttribute('title', description);
rerender(<PaddleObstacleCard detail={withTags({ description: ' ', note: 'Carry round on the right.' })} />);
expect(screen.getByText('Carry round on the right.')).toBeInTheDocument();
rerender(<PaddleObstacleCard detail={withTags({})} />);
expect(screen.queryByText('Carry round on the right.')).not.toBeInTheDocument();
});
it('FE-PADDLE-CARD-011: links to the object on openstreetmap.org and says how current the data is', () => {
const { rerender } = render(<PaddleObstacleCard detail={detailOf(weir(), ON_ROUTE)} />);
const link = screen.getByRole('link', { name: 'View in OpenStreetMap' });
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/29256405');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
const dated = 'OpenStreetMap data as of Sep 1, 2026, not a check on site. Data © OpenStreetMap contributors';
expect(screen.getByText(dated)).toBeInTheDocument();
rerender(<PaddleObstacleCard detail={detailOf(weir(), ON_ROUTE, null)} />);
const undated = 'OpenStreetMap data of an unknown date, not a check on site. Data © OpenStreetMap contributors';
expect(screen.getByText(undated)).toBeInTheDocument();
});
it('FE-PADDLE-CARD-012: offers a close button only when it can be closed', () => {
const onClose = vi.fn();
const detail = detailOf(weir(), ON_ROUTE);
const { rerender } = render(<PaddleObstacleCard detail={detail} />);
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
rerender(<PaddleObstacleCard detail={detail} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-CARD-013: takes the width it is given, and sizes itself to its content without one', () => {
const detail = detailOf(weir(), ON_ROUTE);
const { rerender } = render(<PaddleObstacleCard detail={detail} />);
expect(screen.getByTestId('paddle-obstacle-card')).toHaveClass('w-max');
rerender(<PaddleObstacleCard detail={detail} width={320} />);
const card = screen.getByTestId('paddle-obstacle-card');
expect(card).toHaveStyle({ width: '320px' });
expect(card).not.toHaveClass('w-max');
});
it('FE-PADDLE-CARD-014: puts what the checklist adds below the facts and above the source link', () => {
render(
<PaddleObstacleCard detail={withTags({ height: '3' })}>
<button type="button">Portage round it</button>
</PaddleObstacleCard>
);
const added = screen.getByRole('button', { name: 'Portage round it' });
const facts = screen.getByTestId('paddle-obstacle-access');
expect(facts.compareDocumentPosition(added)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(added.compareDocumentPosition(screen.getByRole('link'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
it('FE-PADDLE-CARD-015: gives the distances in the unit the user reads', () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
const { rerender } = render(<PaddleObstacleCard detail={detailOf(weir(), { ...ON_ROUTE, alongM: 3218 })} />);
expect(valueBeside('On the route at').textContent).toBe('2 mi');
rerender(<PaddleObstacleCard detail={detailOf(weir(), { ...NEAR, distanceM: 805 })} />);
expect(valueBeside('Near the route').textContent).toBe('0.5 mi');
});
});
@@ -0,0 +1,176 @@
import { ExternalLink, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { DistanceUnit } from '../../../types';
import { formatDistance } from '../../../utils/units';
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { FigureBadge } from './FigureBadge';
import { PaddleGlyph } from './PaddleGlyph';
import { OBSTACLE_GLYPH } from './paddleGlyphPaths';
import { KNOWN_ACCESS_VALUES, metresOf, osmUrlOf, type PaddleObstacleDetail } from './paddleObstacleModel';
import { paddleKindCss } from './paddlePaint';
const ACCESS_KEYS: readonly string[] = KNOWN_ACCESS_VALUES;
/** How the object stands to the route: on it at a distance along it, near it and uncertain, or only in the area. */
function RelationBadges({ detail, unit }: { detail: PaddleObstacleDetail; unit: DistanceUnit }) {
const { t } = useTranslation();
return (
<div className="mt-2 flex flex-wrap items-center gap-1.5" data-testid="paddle-obstacle-relation">
{detail.relation === 'on_route' && (
<FigureBadge
label={t('tours.paddle.obstacleCard.onRoute')}
value={formatDistance((detail.alongM ?? 0) / 1000, unit)}
info={t('tours.paddle.obstacleCard.alongInfo')}
/>
)}
{detail.relation === 'near' && (
<>
<FigureBadge
label={t('tours.paddle.obstacleCard.near')}
value={formatDistance((detail.distanceM ?? 0) / 1000, unit)}
info={t('tours.paddle.obstacleCard.distanceInfo')}
/>
<SoftPill>{t('tours.paddle.obstacleCard.uncertain')}</SoftPill>
</>
)}
{detail.relation === 'area' && <SoftPill>{t('tours.paddle.obstacleCard.area')}</SoftPill>}
{detail.privateWay && <SoftPill tone="warning">{t('tours.paddle.obstacleCard.privateWay')}</SoftPill>}
</div>
);
}
/** The paddling rule the tags give (canoe read before boat), its condition as written, and the facts worth knowing. */
function AccessAndFacts({ detail, unit }: { detail: PaddleObstacleDetail; unit: DistanceUnit }) {
const { t } = useTranslation();
const { access } = detail;
const accessValue = (value: string) =>
ACCESS_KEYS.includes(value) ? t(`tours.paddle.obstacleCard.accessValue.${value}`) : value;
const factValue = (key: string, value: string) => {
const metres = key === 'height' || key === 'width' ? metresOf(value) : null;
return metres === null ? value : formatDistance(metres / 1000, unit);
};
const ruled = access.source !== null && access.value !== null;
return (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5" data-testid="paddle-obstacle-access">
{ruled && (
<FigureBadge
label={t(`tours.paddle.obstacleCard.access.${access.source ?? 'access'}`)}
value={accessValue(access.value ?? '')}
tone={access.forbidden || access.private ? 'warning' : 'neutral'}
/>
)}
{!ruled && !access.condition && <SoftPill>{t('tours.paddle.obstacleCard.noAccessInfo')}</SoftPill>}
{access.condition && (
<FigureBadge
label={t('tours.paddle.obstacleCard.condition')}
value={access.condition}
info={access.condition}
/>
)}
{detail.facts.map((fact) => (
<FigureBadge
key={fact.key}
label={t(`tours.paddle.obstacleCard.fact.${fact.key === 'whitewater:section_grade' ? 'grade' : fact.key}`)}
value={factValue(fact.key, fact.value)}
info={fact.value}
/>
))}
</div>
);
}
/** The way to the source and how current it is; a record in the data is no check on site. */
function SourceLines({ detail }: { detail: PaddleObstacleDetail }) {
const { t, language } = useTranslation();
const date = detail.dataAsOf ? new Date(detail.dataAsOf).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
return (
<>
<a
href={osmUrlOf(detail.feature.ref)}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 font-medium text-content hover:underline"
style={fs(12, 'body')}
>
{t('ferratas.viewInOsm')}
<ExternalLink size={13} aria-hidden="true" />
</a>
<p className="mt-2 border-t border-edge-faint pt-2 text-content-faint" style={fs(9)}>
{date ? t('tours.paddle.obstacleCard.source', { date }) : t('tours.paddle.obstacleCard.sourceUndated')}{' '}
{t('ferratas.attribution')}
</p>
</>
);
}
/**
* One paddle object picked on the map or in the checklist (PAD-05B), in the
* look of the via ferrata card: what it is, how it stands to the route (an
* object only near it is said to be uncertain, never on it), the paddling
* rule its tags give with canoe read before boat, the facts worth knowing,
* and where the data comes from and how current it is. A missing rule reads
* as unknown, never as allowed; the card says it is data, not a check on site.
* `children` takes what the checklist adds below the facts.
*/
export default function PaddleObstacleCard({
detail,
onClose,
width,
children,
}: {
detail: PaddleObstacleDetail;
onClose?: () => void;
width?: number;
children?: ReactNode;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
const { feature } = detail;
const kind = t(`tours.paddle.obstacle.kind.${feature.kind}`);
const color = paddleKindCss(feature.kind);
return (
<article
className={`max-h-[70vh] overflow-y-auto overscroll-contain rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
style={{ ...MAP_CARD_GLASS, width }}
data-testid="paddle-obstacle-card"
>
<div className="flex items-start gap-2">
<span
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-surface-card shadow-sm"
style={{ color, boxShadow: `inset 0 0 0 2px ${color}` }}
>
<PaddleGlyph kind={OBSTACLE_GLYPH[feature.kind]} size={15} strokeWidth={2.3} />
</span>
<div className="min-w-0 flex-1">
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={feature.name ?? kind}>
{feature.name ?? kind}
</h3>
{feature.name && <p className="truncate text-caption text-content-muted">{kind}</p>}
</div>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="flex-none rounded-lg p-1 text-content-muted hover:bg-surface-hover hover:text-content"
>
<X size={14} aria-hidden="true" />
</button>
)}
</div>
<RelationBadges detail={detail} unit={unit} />
<AccessAndFacts detail={detail} unit={unit} />
{detail.note && (
<p className="mt-2 line-clamp-3 break-words text-content-muted" style={fs(11.5, 'body')} title={detail.note}>
{detail.note}
</p>
)}
{children}
<SourceLines detail={detail} />
</article>
);
}
@@ -0,0 +1,187 @@
// FE-COMP-PADDLENOTICE-001 to FE-COMP-PADDLENOTICE-011: the one line on what TREK knows about the water.
import type { PaddleCoverageRegion, RelatedObstacle } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { act, render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { clearPaddleObstacles, publishPaddleObstacles } from '../../../store/paddleObstacleStore';
import { useSettingsStore } from '../../../store/settingsStore';
import { PaddleObstacleNotice } from './PaddleObstacleNotice';
type Published = Parameters<typeof publishPaddleObstacles>[0];
const LOADING = 'Loading the obstacles along the route …';
const OFFLINE = 'Without a connection TREK loads no obstacle data. Check the water yourself.';
const FAILED = 'The obstacle data could not be loaded. Check the water yourself.';
const NO_DATA = 'No obstacle data for this area. Check weirs, locks and portages here yourself.';
const TRUNCATED = 'TREK shows only part of the objects here; the area holds more. Check the water yourself.';
const UNKNOWN_DATE = 'an unknown date';
const partialLine = (date: string): string =>
`Obstacle data covers only part of the route (as of ${date}). Check the rest yourself.`;
const coveredLine = (count: number, date: string): string =>
`${count} obstacles and access points along the route, from OpenStreetMap as of ${date}. The data can have gaps, so check the water yourself.`;
const coveredOneLine = (date: string): string =>
`1 obstacle or access point along the route, from OpenStreetMap as of ${date}. The data can have gaps, so check the water yourself.`;
const coveredEmptyLine = (date: string): string =>
`No known obstacles along the route (OpenStreetMap as of ${date}). Check the water yourself all the same.`;
/**
* A region TREK holds data for. Its date is local noon, so it reads as the
* same day in whatever time zone the suite runs.
*/
const region = (id: string, sourceTimestamp: string | null): PaddleCoverageRegion => ({
id,
name: `Region ${id}`,
source_timestamp: sourceTimestamp,
imported_at: '2026-10-01T08:00:00Z',
});
/** A weir the route runs over, `alongM` metres from its start. */
const weir = (ref: string, alongM: number): RelatedObstacle => ({
feature: {
ref,
kind: 'weir',
group_key: null,
name: null,
geometry: { type: 'Point', coordinates: [13.4, 52.5] },
tags: { waterway: 'weir' },
tag_hash: `hash-${ref}`,
region_id: 'brandenburg',
},
relation: 'on_route',
distanceM: 0,
alongM,
});
/** Publishes a ready answer for a route one region covers wholly, with nothing on it; `overrides` change that. */
function publish(overrides: Partial<Published> = {}): void {
publishPaddleObstacles({
status: 'ready',
features: [],
related: [],
routeLengthM: 8000,
coverage: 'covered',
regions: [region('brandenburg', '2026-06-15T12:00:00')],
truncated: false,
...overrides,
});
}
beforeEach(() => {
resetAllStores();
// The data date reads in the user's language.
seedStore(useSettingsStore, { settings: { language: 'en' } });
});
describe('PaddleObstacleNotice', () => {
it('FE-COMP-PADDLENOTICE-001: before anything is read it says the obstacles are loading, as a status with a spinner', () => {
render(<PaddleObstacleNotice />);
expect(screen.getByRole('status')).toHaveTextContent(LOADING);
expect(screen.getByRole('status').querySelector('svg.animate-spin')).not.toBeNull();
});
it('FE-COMP-PADDLENOTICE-002: offline it says no data is loaded, even while the store still holds a covered answer', () => {
publish({ status: 'offline', related: [weir('node/1', 500)] });
render(<PaddleObstacleNotice />);
expect(screen.getByText(OFFLINE)).toBeInTheDocument();
// A warning, not a status line: there is nothing to rely on.
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('FE-COMP-PADDLENOTICE-003: a failed read says the data could not be loaded', () => {
publish({ status: 'error' });
render(<PaddleObstacleNotice />);
expect(screen.getByText(FAILED)).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('FE-COMP-PADDLENOTICE-004: an area without data says to check the water there instead of reading as open water', () => {
publish({ coverage: 'none', regions: [], truncated: true });
render(<PaddleObstacleNotice />);
expect(screen.getByText(NO_DATA)).toBeInTheDocument();
expect(screen.queryByText(TRUNCATED)).not.toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('FE-COMP-PADDLENOTICE-005: a partly covered route names the oldest date among its regions', () => {
publish({
coverage: 'partial',
// Out of order and one without a date: the route is only as current as its oldest dated part.
regions: [region('spree', '2026-06-20T12:00:00'), region('havel', '2026-05-10T12:00:00'), region('oder', null)],
truncated: true,
});
render(<PaddleObstacleNotice />);
expect(screen.getByText(partialLine('May 10, 2026'))).toBeInTheDocument();
// That only part of the route is covered matters more than an answer cut at its limit.
expect(screen.queryByText(TRUNCATED)).not.toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('FE-COMP-PADDLENOTICE-006: without a dated region the date reads as unknown', () => {
publish({ coverage: 'partial', regions: [region('spree', null), region('havel', '')] });
render(<PaddleObstacleNotice />);
expect(screen.getByText(partialLine(UNKNOWN_DATE))).toBeInTheDocument();
});
it('FE-COMP-PADDLENOTICE-007: an answer cut at its limit says the area holds more instead of counting', () => {
publish({ truncated: true, related: [weir('node/1', 500), weir('node/2', 2500)] });
render(<PaddleObstacleNotice />);
expect(screen.getByText(TRUNCATED)).toBeInTheDocument();
expect(screen.queryByText(/obstacles and access points/)).not.toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('FE-COMP-PADDLENOTICE-008: a covered route counts what lies on or near it, with the date, as a status', () => {
const related: RelatedObstacle[] = [
weir('node/1', 500),
weir('way/2', 2500),
{ ...weir('node/3', 6000), relation: 'near', distanceM: 90 },
];
// A fourth object is read in the area but lies off the route, so it is not counted.
publish({ features: [...related.map((item) => item.feature), weir('node/4', 0).feature], related });
render(<PaddleObstacleNotice />);
expect(screen.getByRole('status')).toHaveTextContent(coveredLine(3, 'Jun 15, 2026'));
});
it('FE-COMP-PADDLENOTICE-009: one object on the route reads in the singular', () => {
publish({ related: [weir('node/1', 500)] });
render(<PaddleObstacleNotice />);
expect(screen.getByRole('status')).toHaveTextContent(coveredOneLine('Jun 15, 2026'));
});
it('FE-COMP-PADDLENOTICE-010: a covered route with nothing on it still says to check the water, dated or not', () => {
publish();
render(<PaddleObstacleNotice />);
expect(screen.getByRole('status')).toHaveTextContent(coveredEmptyLine('Jun 15, 2026'));
act(() => {
publish({ regions: [region('brandenburg', null)] });
});
expect(screen.getByRole('status')).toHaveTextContent(coveredEmptyLine(UNKNOWN_DATE));
});
it('FE-COMP-PADDLENOTICE-011: follows the store from loading to the answer, and back when the tour closes', () => {
publish({ status: 'loading' });
render(<PaddleObstacleNotice />);
expect(screen.getByRole('status')).toHaveTextContent(LOADING);
act(() => {
publish({ related: [weir('node/1', 500)] });
});
expect(screen.getByRole('status')).toHaveTextContent(coveredOneLine('Jun 15, 2026'));
act(() => {
clearPaddleObstacles();
});
expect(screen.getByRole('status')).toHaveTextContent(LOADING);
});
});
@@ -0,0 +1,58 @@
import { compareCodeUnits } from '@trek/shared';
import { AlertTriangle, Info, Loader2, WifiOff } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../../i18n';
import { usePaddleObstacleStore } from '../../../store/paddleObstacleStore';
import { TourNotice } from '../tourParts';
/** The oldest data date among the regions, as a short local date; the route is only as current as its oldest part. */
function dataDate(regions: readonly { source_timestamp?: string | null }[], language: string): string | null {
const times = regions
.map((region) => region.source_timestamp)
.filter((time): time is string => !!time)
.sort(compareCodeUnits);
return times[0] ? new Date(times[0]).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
}
/**
* PAD-05B: what TREK knows about the water along the route, in one line of
* the checklist card until a new paddle tour is saved. It never reads as an
* all-clear: covered or not, the paddler checks the water, where nothing is
* known it says so instead of leaving an empty map to be read as open water,
* and an answer cut at its limit says that more is there.
*/
export function PaddleObstacleNotice() {
const { t, language } = useTranslation();
const { status, coverage, related, regions, truncated } = usePaddleObstacleStore(
useShallow((state) => ({
status: state.status,
coverage: state.coverage,
related: state.related,
regions: state.regions,
truncated: state.truncated,
}))
);
const warn = <AlertTriangle size={13} strokeWidth={2} />;
if (status === 'idle' || status === 'loading') {
return (
<TourNotice role="status" icon={<Loader2 size={13} strokeWidth={2} className="animate-spin" />}>
{t('tours.paddle.obstacles.loading')}
</TourNotice>
);
}
if (status === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.paddle.obstacles.offline')}</TourNotice>;
if (status === 'error') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.error')}</TourNotice>;
const date = dataDate(regions, language) ?? t('tours.paddle.obstacles.dateUnknown');
if (coverage === 'none') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.none')}</TourNotice>;
if (coverage === 'partial')
return <TourNotice icon={warn}>{t('tours.paddle.obstacles.partial', { date })}</TourNotice>;
if (truncated) return <TourNotice icon={warn}>{t('tours.paddle.obstacles.truncated')}</TourNotice>;
return (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{related.length
? t('tours.paddle.obstacles.covered', { count: related.length, date })
: t('tours.paddle.obstacles.coveredEmpty', { date })}
</TourNotice>
);
}
@@ -0,0 +1,233 @@
// FE-COMP-PADDLEOVERLAY-001 to FE-COMP-PADDLEOVERLAY-008: the picked paddle object's card over the planner map,
// opened and closed through the obstacle store, and the ways it closes.
import type { ComponentProps } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PaddleCoverageRegion, PaddleFeature, RelatedObstacle } from '@trek/shared';
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
import { resetAllStores } from '../../../../tests/helpers/store';
import {
publishPaddleObstacles,
selectPaddleObstacle,
usePaddleObstacleStore,
} from '../../../store/paddleObstacleStore';
import PaddleObstacleOverlay from './PaddleObstacleOverlay';
const REGION: PaddleCoverageRegion = {
id: 'hessen',
name: 'Hessen',
source_timestamp: '2026-06-01T12:00:00Z',
imported_at: '2026-06-02T08:00:00Z',
};
const WEIR: PaddleFeature = {
ref: 'way/101',
kind: 'weir',
group_key: null,
name: 'Hammer weir',
geometry: {
type: 'LineString',
coordinates: [
[8.1, 50.1],
[8.101, 50.1],
],
},
tags: { waterway: 'weir' },
tag_hash: 'weir-hash',
region_id: 'hessen',
};
/** Unnamed and from a region the answer does not hold, so its card reads its kind and an unknown date. */
const SLIPWAY: PaddleFeature = {
ref: 'node/202',
kind: 'slipway',
group_key: null,
name: null,
geometry: { type: 'Point', coordinates: [8.2, 50.2] },
tags: { leisure: 'slipway' },
tag_hash: 'slipway-hash',
region_id: 'elsewhere',
};
const WEIR_ON_ROUTE: RelatedObstacle = { feature: WEIR, relation: 'on_route', distanceM: 4, alongM: 2500 };
function publish(features: PaddleFeature[], related: RelatedObstacle[] = []): void {
publishPaddleObstacles({
status: 'ready',
features,
related,
routeLengthM: 12_000,
coverage: 'covered',
regions: [REGION],
truncated: false,
});
}
function renderOverlay(props: Partial<ComponentProps<typeof PaddleObstacleOverlay>> = {}): void {
render(
<div>
<button type="button">map</button>
<PaddleObstacleOverlay insetEnd={400} bottom={66} {...props} />
</div>
);
}
const overlay = () => screen.queryByTestId('paddle-obstacle-overlay');
const selectedRef = () => usePaddleObstacleStore.getState().selectedRef;
const pressMap = () => fireEvent.pointerDown(screen.getByRole('button', { name: 'map' }));
describe('PaddleObstacleOverlay', () => {
beforeEach(() => {
resetAllStores();
});
it('FE-COMP-PADDLEOVERLAY-001: shows nothing and leaves presses and keys alone while no known object is picked', () => {
const onOutsidePress = vi.fn();
publish([WEIR]);
renderOverlay({ onOutsidePress });
expect(overlay()).toBeNull();
// A pick the store holds no object for opens nothing either.
act(() => selectPaddleObstacle('node/999'));
expect(overlay()).toBeNull();
pressMap();
fireEvent.keyDown(document, { key: 'Escape' });
expect(onOutsidePress).not.toHaveBeenCalled();
expect(selectedRef()).toBe('node/999');
});
it('FE-COMP-PADDLEOVERLAY-002: anchors the card of the pick at the bottom of the inline end, clear of the panel', () => {
publish([WEIR], [WEIR_ON_ROUTE]);
selectPaddleObstacle(WEIR.ref);
renderOverlay({ width: 320 });
const shown = screen.getByTestId('paddle-obstacle-overlay');
expect(shown.style.insetInlineEnd).toBe('400px');
// Logical only: no physical side, so a right-to-left page mirrors it.
expect(shown.style.right).toBe('');
expect(shown.style.left).toBe('');
expect(shown.style.bottom).toBe('66px');
expect(shown.style.maxWidth).toBe('calc(100% - 432px)');
const card = screen.getByTestId('paddle-obstacle-card');
expect(shown).toContainElement(card);
expect(card.style.width).toBe('320px');
expect(screen.getByRole('heading', { name: 'Hammer weir' })).toBeInTheDocument();
expect(screen.getByText('Weir')).toBeInTheDocument();
// The relation and the date of the data come out of the same store as the pick.
expect(screen.getByText('On the route at')).toBeInTheDocument();
expect(screen.getByText(/^OpenStreetMap data as of /)).toBeInTheDocument();
});
it('FE-COMP-PADDLEOVERLAY-003: closes on a press outside it and hands that press on, never on a press inside it', () => {
const onOutsidePress = vi.fn();
publish([WEIR]);
selectPaddleObstacle(WEIR.ref);
renderOverlay({ onOutsidePress });
fireEvent.pointerDown(screen.getByRole('heading', { name: 'Hammer weir' }));
expect(onOutsidePress).not.toHaveBeenCalled();
expect(selectedRef()).toBe(WEIR.ref);
expect(overlay()).toBeInTheDocument();
pressMap();
expect(onOutsidePress).toHaveBeenCalledTimes(1);
expect(selectedRef()).toBeNull();
expect(overlay()).toBeNull();
// Once closed it stops listening, so the next press on the map is the map's alone.
pressMap();
expect(onOutsidePress).toHaveBeenCalledTimes(1);
});
it('FE-COMP-PADDLEOVERLAY-004: closes on a press outside it without anyone to hand the press to', () => {
publish([WEIR]);
selectPaddleObstacle(WEIR.ref);
renderOverlay();
fireEvent.pointerDown(document.body);
expect(selectedRef()).toBeNull();
expect(overlay()).toBeNull();
});
it('FE-COMP-PADDLEOVERLAY-005: closes on Escape, not on another key nor on an Escape something else already took', () => {
const onOutsidePress = vi.fn();
publish([WEIR]);
selectPaddleObstacle(WEIR.ref);
renderOverlay({ onOutsidePress });
fireEvent.keyDown(document, { key: 'Enter' });
expect(overlay()).toBeInTheDocument();
// A dialog above the map that handles its own Escape keeps the card open.
const takeEscape = (event: KeyboardEvent) => {
event.preventDefault();
};
document.addEventListener('keydown', takeEscape, true);
fireEvent.keyDown(document.body, { key: 'Escape' });
document.removeEventListener('keydown', takeEscape, true);
expect(overlay()).toBeInTheDocument();
expect(selectedRef()).toBe(WEIR.ref);
fireEvent.keyDown(document, { key: 'Escape' });
expect(selectedRef()).toBeNull();
expect(overlay()).toBeNull();
// A key is not a press: the map has no click to leave alone.
expect(onOutsidePress).not.toHaveBeenCalled();
});
it('FE-COMP-PADDLEOVERLAY-006: closes from its own close button without reporting a press outside', () => {
const onOutsidePress = vi.fn();
publish([WEIR]);
selectPaddleObstacle(WEIR.ref);
renderOverlay({ onOutsidePress });
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(selectedRef()).toBeNull();
expect(overlay()).toBeNull();
expect(onOutsidePress).not.toHaveBeenCalled();
});
it('FE-COMP-PADDLEOVERLAY-007: puts the checklist part for the picked object inside the card, where a press keeps it open', () => {
const onOutsidePress = vi.fn();
publish([WEIR], [WEIR_ON_ROUTE]);
selectPaddleObstacle(WEIR.ref);
renderOverlay({
onOutsidePress,
extra: (detail) => <button type="button">{`Decide ${detail.feature.ref} ${detail.relation}`}</button>,
});
const decide = screen.getByRole('button', { name: 'Decide way/101 on_route' });
expect(screen.getByTestId('paddle-obstacle-card')).toContainElement(decide);
fireEvent.pointerDown(decide);
expect(onOutsidePress).not.toHaveBeenCalled();
expect(selectedRef()).toBe(WEIR.ref);
expect(overlay()).toBeInTheDocument();
});
it('FE-COMP-PADDLEOVERLAY-008: follows the pick made elsewhere and closes when the object leaves the store', () => {
const onOutsidePress = vi.fn();
publish([WEIR, SLIPWAY]);
renderOverlay({ onOutsidePress });
expect(overlay()).toBeNull();
act(() => selectPaddleObstacle(WEIR.ref));
expect(screen.getByRole('heading', { name: 'Hammer weir' })).toBeInTheDocument();
// The checklist picks another object: the same card shows that one, named by its kind.
act(() => selectPaddleObstacle(SLIPWAY.ref));
expect(screen.getByRole('heading', { name: 'Slipway' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Hammer weir' })).toBeNull();
expect(screen.getByText(/^OpenStreetMap data of an unknown date/)).toBeInTheDocument();
// A refresh without the object drops the pick and the card with it, and the card stops listening.
act(() => publish([WEIR]));
expect(selectedRef()).toBeNull();
expect(overlay()).toBeNull();
pressMap();
expect(onOutsidePress).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,57 @@
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { selectPaddleObstacle, usePaddleObstacleStore } from '../../../store/paddleObstacleStore';
import { useMapCardDismiss } from '../../Map/useMapCardDismiss';
import PaddleObstacleCard from './PaddleObstacleCard';
import { paddleObstacleDetail, type PaddleObstacleDetail } from './paddleObstacleModel';
/**
* The picked paddle object's card over the planner map (PAD-05B), where a
* via ferrata's card sits and closing as that one does: on a press outside
* it and on Escape, the press handed on so the map leaves its click alone.
* The pick lives in the obstacle store, so the map and the checklist open
* and close the same card. `extra` adds the checklist's part for the object.
*/
export default function PaddleObstacleOverlay({
onOutsidePress,
insetEnd,
bottom,
width,
extra,
}: {
onOutsidePress?: () => void;
insetEnd: number;
bottom: number;
width?: number;
extra?: (detail: PaddleObstacleDetail) => ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const { features, related, regions, selectedRef } = usePaddleObstacleStore(
useShallow((state) => ({
features: state.features,
related: state.related,
regions: state.regions,
selectedRef: state.selectedRef,
}))
);
const detail = useMemo(
() => paddleObstacleDetail({ features, related, regions, selectedRef }),
[features, related, regions, selectedRef]
);
const close = useCallback(() => selectPaddleObstacle(null), []);
useMapCardDismiss(ref, !!detail, close, onOutsidePress);
if (!detail) return null;
return (
<div
ref={ref}
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
data-testid="paddle-obstacle-overlay"
>
<PaddleObstacleCard detail={detail} onClose={close} width={width}>
{extra?.(detail)}
</PaddleObstacleCard>
</div>
);
}
@@ -0,0 +1,235 @@
// FE-COMP-PADDLE-STRIP-001 to FE-COMP-PADDLE-STRIP-011: the band of a paddle tour, water and portages to scale,
// with its locks and the obstacles on or near the route.
import type { PaddleFeature, PaddleLayout, PaddleObstacleKind, PaddleRelation, RelatedObstacle } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { buildSettings } from '../../../../tests/helpers/factories';
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import {
publishPaddleObstacles,
selectPaddleObstacle,
usePaddleObstacleStore,
} from '../../../store/paddleObstacleStore';
import { useSettingsStore } from '../../../store/settingsStore';
import { PaddleRouteStrip } from './PaddleRouteStrip';
import { paddleKindCss, PORTAGE_CSS_COLOR } from './paddlePaint';
/** Eight kilometres: water, a carried kilometre, water, a 40 m carry round the second lock, water. */
const LAYOUT: PaddleLayout = {
totalM: 8000,
stretches: [
{ kind: 'water', fromM: 0, toM: 4000, extraMinutes: null, startIndex: 0 },
{ kind: 'portage', fromM: 4000, toM: 5000, extraMinutes: 10, startIndex: 2 },
{ kind: 'water', fromM: 5000, toM: 6000, extraMinutes: null, startIndex: 3 },
{ kind: 'portage', fromM: 6000, toM: 6040, extraMinutes: null, startIndex: 4 },
{ kind: 'water', fromM: 6040, toM: 8000, extraMinutes: null, startIndex: 5 },
],
locks: [
{ wait_minutes: 10, transit_minutes: 15, atM: 2000, bypassed: false, index: 1 },
{ wait_minutes: null, transit_minutes: null, atM: 6000, bypassed: true, index: null },
],
};
function feature(ref: string, kind: PaddleObstacleKind, name: string | null = null): PaddleFeature {
return {
ref,
kind,
group_key: null,
name,
geometry: { type: 'Point', coordinates: [12.1, 49.1] },
tags: {},
tag_hash: ref,
region_id: 'bayern',
};
}
function relate(entry: PaddleFeature, relation: PaddleRelation, alongM: number): RelatedObstacle {
return { feature: entry, relation, distanceM: relation === 'on_route' ? 0 : 90, alongM };
}
/** Measured along a route of 4 km, so each one sits on the 8 km band at twice its distance. */
const RELATED: RelatedObstacle[] = [
relate(feature('node/1', 'put_in', 'Bootsrampe'), 'on_route', 0),
relate(feature('way/2', 'weir', 'Wehr Regensburg'), 'on_route', 1500),
relate(feature('way/3', 'access', 'Naturschutzgebiet'), 'near', 1800),
relate(feature('node/4', 'rapids'), 'near', 2500),
relate(feature('way/5', 'dam', 'Staustufe'), 'on_route', 4400),
];
function publish(related: RelatedObstacle[], routeLengthM = 4000): void {
publishPaddleObstacles({
status: 'ready',
features: related.map((entry) => entry.feature),
related,
routeLengthM,
coverage: 'covered',
regions: [],
truncated: false,
});
}
const marks = () => screen.queryAllByTestId('paddle-route-strip-obstacle');
const locks = () => screen.queryAllByTestId('paddle-route-strip-lock');
const stretchesOf = () =>
Array.from(screen.getByTestId('paddle-route-strip').querySelectorAll<HTMLElement>('[data-stretch]'));
/** The tooltip a hover opens on the element, closed again afterwards. */
async function tooltipOf(element: HTMLElement): Promise<string | null> {
fireEvent.mouseEnter(element);
const tip = await screen.findByRole('tooltip');
const text = tip.textContent;
fireEvent.mouseLeave(element);
return text;
}
describe('PaddleRouteStrip', () => {
beforeEach(() => {
resetAllStores();
});
it('FE-COMP-PADDLE-STRIP-001: draws water and portages in order and to scale, a short carry at its least width', () => {
render(<PaddleRouteStrip layout={LAYOUT} />);
const parts = stretchesOf();
expect(parts.map((part) => part.dataset.stretch)).toEqual(['water', 'portage', 'water', 'portage', 'water']);
// Half, an eighth and an eighth of the 8 km; the 40 m carry is drawn at the 1.5 floor instead of 0.5.
expect(parts.slice(0, 4).map((part) => part.style.flexGrow)).toEqual(['50', '12.5', '12.5', '1.5']);
expect(parts[0]).toHaveClass('bg-info');
expect(parts[1]).not.toHaveClass('bg-info');
expect(parts[1]?.getAttribute('style')).toContain(`background: ${PORTAGE_CSS_COLOR}`);
// The scale runs from the put-in to the take-out.
expect(screen.getByText('0 m')).toBeInTheDocument();
expect(screen.getByText('8 km')).toBeInTheDocument();
});
it('FE-COMP-PADDLE-STRIP-002: names each stretch with its length on hover', async () => {
render(<PaddleRouteStrip layout={LAYOUT} />);
const parts = stretchesOf();
expect(await tooltipOf(parts[0])).toBe('On the water 4 km');
expect(await tooltipOf(parts[1])).toBe('Portage 1 km');
expect(await tooltipOf(parts[3])).toBe('Portage 40 m');
});
it('FE-COMP-PADDLE-STRIP-003: marks each lock at its place, one a portage carries round faded', async () => {
render(<PaddleRouteStrip layout={LAYOUT} />);
const [open, carried] = locks();
expect(locks()).toHaveLength(2);
expect(open.style.insetInlineStart).toBe('25%');
expect(open).not.toHaveClass('opacity-45');
expect(open.querySelector('[data-paddle-glyph="lock"]')).toBeInTheDocument();
expect(carried.style.insetInlineStart).toBe('75%');
expect(carried).toHaveClass('opacity-45');
expect(await tooltipOf(open)).toBe('Lock 2 km');
expect(await tooltipOf(carried)).toBe('Lock 6 km');
});
it('FE-COMP-PADDLE-STRIP-004: places the obstacles to decide about by their share of the measured route', async () => {
publish(RELATED);
render(<PaddleRouteStrip layout={LAYOUT} />);
// The put-in helps and the access rule runs along a stretch: neither becomes a mark.
expect(marks()).toHaveLength(3);
const [weir, rapids, dam] = marks();
expect(weir).toHaveAccessibleName('Weir: Wehr Regensburg');
// 1.5 of the 4 km measured is 3 of the 8 km drawn.
expect(weir.style.insetInlineStart).toBe('37.5%');
expect(weir.querySelector('[data-paddle-glyph="weir"]')).toBeInTheDocument();
expect(weir?.getAttribute('style')).toContain(`color: ${paddleKindCss('weir')}`);
expect(rapids).toHaveAccessibleName('Rapids');
expect(rapids.style.insetInlineStart).toBe('62.5%');
// Measured past the end of the route, it still sits at the take-out.
expect(dam).toHaveAccessibleName('Dam: Staustufe');
expect(dam.style.insetInlineStart).toBe('100%');
expect(await tooltipOf(weir)).toBe('Weir: Wehr Regensburg 3 km');
expect(await tooltipOf(rapids)).toBe('Rapids 5 km');
expect(await tooltipOf(dam)).toBe('Dam: Staustufe 8 km');
});
it('FE-COMP-PADDLE-STRIP-005: rings an obstacle on the route solid and dims one only near it, dashed', () => {
publish(RELATED);
render(<PaddleRouteStrip layout={LAYOUT} />);
const [weir, rapids] = marks();
expect(weir.style.border).toContain('solid');
expect(weir).not.toHaveClass('opacity-60');
expect(rapids.style.border).toContain('dashed');
expect(rapids).toHaveClass('opacity-60');
});
it('FE-COMP-PADDLE-STRIP-006: a click picks an obstacle for its card and a second click puts it back', () => {
publish(RELATED);
render(<PaddleRouteStrip layout={LAYOUT} />);
const rapids = screen.getByRole('button', { name: 'Rapids' });
expect(rapids).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(rapids);
expect(usePaddleObstacleStore.getState().selectedRef).toBe('node/4');
expect(rapids).toHaveAttribute('aria-pressed', 'true');
expect(rapids).toHaveClass('scale-125', 'z-30');
// Picked, a near one shows in full: no dimming and a solid ring.
expect(rapids).not.toHaveClass('opacity-60');
expect(rapids.style.border).toContain('solid');
expect(screen.getByRole('button', { name: 'Weir: Wehr Regensburg' })).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(rapids);
expect(usePaddleObstacleStore.getState().selectedRef).toBeNull();
expect(rapids).toHaveAttribute('aria-pressed', 'false');
expect(rapids).toHaveClass('z-20', 'opacity-60');
});
it('FE-COMP-PADDLE-STRIP-007: shows the obstacle picked elsewhere as picked, and a click moves the pick', () => {
publish(RELATED);
render(<PaddleRouteStrip layout={LAYOUT} />);
act(() => selectPaddleObstacle('way/5'));
expect(screen.getByRole('button', { name: 'Dam: Staustufe' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Weir: Wehr Regensburg' }));
expect(usePaddleObstacleStore.getState().selectedRef).toBe('way/2');
expect(screen.getByRole('button', { name: 'Dam: Staustufe' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: 'Weir: Wehr Regensburg' })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-COMP-PADDLE-STRIP-008: places no obstacle while the route they were measured along has no length', () => {
publish(RELATED, 0);
render(<PaddleRouteStrip layout={LAYOUT} />);
expect(marks()).toHaveLength(0);
expect(locks()).toHaveLength(2);
});
it('FE-COMP-PADDLE-STRIP-009: compact, it draws the band alone, without locks, obstacles or the scale', () => {
publish(RELATED);
render(<PaddleRouteStrip layout={LAYOUT} compact />);
expect(stretchesOf()).toHaveLength(5);
expect(locks()).toHaveLength(0);
expect(marks()).toHaveLength(0);
expect(screen.queryByText('0 m')).not.toBeInTheDocument();
expect(screen.queryByText('8 km')).not.toBeInTheDocument();
});
it('FE-COMP-PADDLE-STRIP-010: draws nothing for a tour without length or without stretches', () => {
publish(RELATED);
const { rerender } = render(<PaddleRouteStrip layout={{ ...LAYOUT, totalM: 0 }} />);
expect(screen.queryByTestId('paddle-route-strip')).not.toBeInTheDocument();
rerender(<PaddleRouteStrip layout={{ ...LAYOUT, stretches: [] }} />);
expect(screen.queryByTestId('paddle-route-strip')).not.toBeInTheDocument();
expect(marks()).toHaveLength(0);
});
it('FE-COMP-PADDLE-STRIP-011: reads its lengths in the unit the user chose, metric when none is set', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
render(<PaddleRouteStrip layout={LAYOUT} />);
expect(screen.getByText('0 mi')).toBeInTheDocument();
expect(screen.getByText('5 mi')).toBeInTheDocument();
expect(await tooltipOf(locks()[0])).toBe('Lock 1.2 mi');
act(() => seedStore(useSettingsStore, { settings: buildSettings() }));
expect(screen.getByText('8 km')).toBeInTheDocument();
});
});
@@ -0,0 +1,120 @@
import type { PaddleLayout } from '@trek/shared';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../../i18n';
import { focusPaddleObstacle, selectPaddleObstacle, usePaddleObstacleStore } from '../../../store/paddleObstacleStore';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { PaddleGlyph } from './PaddleGlyph';
import { OBSTACLE_GLYPH } from './paddleGlyphPaths';
import { routeStripObstacles } from './paddleObstacleModel';
import { paddleKindCss, PORTAGE_CSS_COLOR } from './paddlePaint';
/** Water in the band and in the tiles that explain it: the info blue of the theme. */
export const WATER_COLOR = 'var(--info)';
/** The narrowest a stretch is drawn, in percent, so a short portage still shows. */
const MIN_SHARE = 1.5;
/**
* A paddle tour from put-in to take-out as one band, to scale: water in the
* planner's blue, portages in the earth brown the map draws them in, each lock
* a mark at its place (struck through where a portage carries round it). The
* obstacles the data knows on or near the route sit on it too, ringed in the
* colour of their kind, dashed when only near; a click opens one's card. It
* stands where a hike shows its elevation profile; a paddle tour has none.
*/
export function PaddleRouteStrip({ layout, compact = false }: { layout: PaddleLayout; compact?: boolean }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const { related, routeLengthM, selectedRef } = usePaddleObstacleStore(
useShallow((state) => ({
related: state.related,
routeLengthM: state.routeLengthM,
selectedRef: state.selectedRef,
}))
);
const total = layout.totalM;
const obstacles = useMemo(
() => (compact ? [] : routeStripObstacles(related, routeLengthM, total)),
[compact, related, routeLengthM, total]
);
if (total <= 0 || layout.stretches.length === 0) return null;
const share = (m: number) => Math.max(MIN_SHARE, (m / total) * 100);
const km = (m: number) => formatDistance(m / 1000, unit);
return (
<div data-testid="paddle-route-strip" className="min-w-0">
<div className={`relative ${compact ? '' : 'py-[3px]'}`}>
{!compact &&
layout.locks.map((lock) => (
<Tooltip key={`${lock.atM}-${lock.index ?? ''}`} label={`${t('tours.paddle.lock')} ${km(lock.atM)}`}>
<span
data-testid="paddle-route-strip-lock"
className={`absolute top-1/2 z-10 grid h-4 w-4 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge rtl:translate-x-1/2 ${lock.bypassed ? 'opacity-45' : ''}`}
style={{ insetInlineStart: `${(lock.atM / total) * 100}%` }}
>
<PaddleGlyph kind="lock" size={10} strokeWidth={2.6} />
</span>
</Tooltip>
))}
{obstacles.map((obstacle) => {
const kind = t(`tours.paddle.obstacle.kind.${obstacle.kind}`);
const color = paddleKindCss(obstacle.kind);
const picked = obstacle.ref === selectedRef;
return (
<Tooltip
key={obstacle.ref}
label={`${obstacle.name ? `${kind}: ${obstacle.name}` : kind} ${km(obstacle.atM)}`}
>
<button
type="button"
data-testid="paddle-route-strip-obstacle"
aria-label={obstacle.name ? `${kind}: ${obstacle.name}` : kind}
aria-pressed={picked}
onClick={() => (picked ? selectPaddleObstacle(null) : focusPaddleObstacle(obstacle.ref))}
className={`absolute top-1/2 grid h-4 w-4 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-surface-card shadow-sm rtl:translate-x-1/2 ${picked ? 'z-30 scale-125' : 'z-20'} ${obstacle.relation === 'near' && !picked ? 'opacity-60' : ''}`}
style={{
insetInlineStart: `${(obstacle.atM / total) * 100}%`,
color,
border: `1.5px ${obstacle.relation === 'near' && !picked ? 'dashed' : 'solid'} ${color}`,
}}
>
<PaddleGlyph kind={OBSTACLE_GLYPH[obstacle.kind]} size={9} strokeWidth={2.6} />
</button>
</Tooltip>
);
})}
<div className="flex h-2.5 w-full gap-[2px] overflow-hidden rounded-full">
{layout.stretches.map((stretch) => {
const land = stretch.kind === 'portage';
const length = stretch.toM - stretch.fromM;
return (
<Tooltip
key={`${stretch.kind}-${stretch.fromM}`}
label={`${t(land ? 'tours.paddle.portage' : 'tours.paddle.time.water')} ${km(length)}`}
>
<span
data-stretch={stretch.kind}
className={`h-full min-w-[3px] ${land ? '' : 'bg-info'}`}
style={{
flexGrow: share(length),
flexBasis: 0,
...(land ? { background: PORTAGE_CSS_COLOR } : {}),
}}
/>
</Tooltip>
);
})}
</div>
</div>
{!compact && (
<div className="mt-1 flex justify-between font-geist tabular-nums text-content-faint" style={fs(10)}>
<span>{km(0)}</span>
<span>{km(total)}</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,129 @@
// PSF-001 to PSF-006: the safety note of a paddle tour: the quiet row and the dialog it opens, which never asks for
// a confirmation, never stands in the way and closes on Escape without taking the tour detail with it.
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { PaddleSafetyDialog, PaddleSafetyRow } from './PaddleSafety';
/** The note as shared/src/i18n/en/tours.ts words it. */
const SHORT = 'Paddling has risks. Check water, rules and weather yourself.';
const TITLE = 'Safety on the water';
const TEXT =
'Paddling carries risks. Maps, route suggestions and notes can be incomplete or out of date. Before the tour, check the current local conditions, the rules for using the water and the weather. Take suitable safety measures. TREK supports your planning and does not replace your own check and responsible decision.';
/** The footer's Close, told apart from the round close of the head band by its words. */
function footerClose(dialog: HTMLElement): HTMLElement {
const button = within(dialog).getByText('Close').closest('button');
if (!button) throw new Error('no Close button in the footer');
return button;
}
/** The round close of the head band, named by its label alone. */
function headClose(dialog: HTMLElement): HTMLElement {
const button = within(dialog)
.getAllByRole('button', { name: 'Close' })
.find((candidate) => candidate.textContent === '');
if (!button) throw new Error('no close button in the head band');
return button;
}
describe('PaddleSafetyRow', () => {
it('PSF-001: is a quiet button saying paddling has risks that opens a dialog, closed at first', () => {
render(<PaddleSafetyRow />);
const row = screen.getByRole('button', { name: SHORT });
expect(row).toHaveAttribute('type', 'button');
expect(row).toHaveAttribute('aria-haspopup', 'dialog');
expect(row.querySelector('svg.lucide-life-buoy')).not.toBeNull();
expect(row.querySelector('svg.lucide-chevron-right')).not.toBeNull();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('PSF-002: opens the full note, closes on Close and opens again', () => {
render(<PaddleSafetyRow />);
const row = screen.getByRole('button', { name: SHORT });
fireEvent.click(row);
const dialog = screen.getByRole('dialog', { name: TITLE });
expect(within(dialog).getByText(TEXT)).toBeInTheDocument();
fireEvent.click(footerClose(dialog));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(row);
expect(screen.getByRole('dialog', { name: TITLE })).toBeInTheDocument();
});
it('PSF-003: the round close, Escape and the backdrop close it as well', () => {
render(<PaddleSafetyRow />);
const row = screen.getByRole('button', { name: SHORT });
fireEvent.click(row);
fireEvent.click(headClose(screen.getByRole('dialog', { name: TITLE })));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(row);
fireEvent.keyDown(screen.getByRole('dialog', { name: TITLE }), { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(row);
const backdrop = screen.getByRole('dialog', { name: TITLE }).parentElement;
if (!backdrop) throw new Error('no backdrop around the dialog');
fireEvent.mouseDown(backdrop);
fireEvent.click(backdrop);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('PSF-004: Escape closes the note alone; the tour detail listening on the page does not see it', () => {
// The tour detail closes on an Escape nobody handled, from a listener it registered before the note opened.
const detailEscape = vi.fn();
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !event.defaultPrevented) detailEscape();
};
document.addEventListener('keydown', onKey);
try {
render(<PaddleSafetyRow />);
fireEvent.click(screen.getByRole('button', { name: SHORT }));
fireEvent.keyDown(screen.getByRole('dialog', { name: TITLE }), { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(detailEscape).not.toHaveBeenCalled();
} finally {
document.removeEventListener('keydown', onKey);
}
});
it('PSF-005: inside a form the row and its dialog submit nothing', () => {
const onSubmit = vi.fn();
render(
<form onSubmit={onSubmit}>
<PaddleSafetyRow />
</form>
);
fireEvent.click(screen.getByRole('button', { name: SHORT }));
fireEvent.click(footerClose(screen.getByRole('dialog', { name: TITLE })));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
describe('PaddleSafetyDialog', () => {
it('PSF-006: shows nothing while closed; open it is narrow, its one action closes it and nothing asks to confirm', () => {
const onClose = vi.fn();
const { rerender } = render(<PaddleSafetyDialog open={false} onClose={onClose} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<PaddleSafetyDialog open onClose={onClose} />);
const dialog = screen.getByRole('dialog', { name: TITLE });
expect(dialog.className).toContain('max-w-[520px]');
expect(within(dialog).getAllByRole('button')).toHaveLength(2);
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
fireEvent.click(footerClose(dialog));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,101 @@
import { ChevronRight, LifeBuoy } from 'lucide-react';
import { useCallback, useEffect, useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
/**
* Escape closes the note and not the tour detail or panel it was opened from:
* captured and marked handled before their own listeners on the document see it.
*/
function useOwnEscape(open: boolean, onClose: () => void) {
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
event.preventDefault();
onClose();
};
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
}, [open, onClose]);
}
/**
* The safety note for paddle tours in full: what paddling risks and what to
* check before setting out. It is read and closed; it asks for no
* confirmation and blocks nothing.
*/
export function PaddleSafetyDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const labelId = useId();
useOwnEscape(open, onClose);
return (
<DialogShell
open={open}
onClose={onClose}
labelledBy={labelId}
width="narrow"
header={
<DialogHeader
tile={
<DialogTile>
<LifeBuoy size={20} strokeWidth={1.9} className="text-content-muted" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('tours.paddle.safety.title')}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={onClose}>
{t('common.close')}
</DialogButton>
</DialogFooter>
}
>
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13, 'body')}>
{t('tours.paddle.safety.text')}
</p>
</DialogShell>
);
}
/**
* The reminder that paddling has risks, as a quiet row in the look of a
* panel's notices. It opens the full note and never stands in the way of
* planning or saving.
*/
export function PaddleSafetyRow() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
<>
<button
type="button"
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className="flex w-full items-start gap-2 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2 text-start leading-snug text-content-muted transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(12, 'body')}
>
<LifeBuoy size={13} strokeWidth={2} aria-hidden="true" className="mt-[2px] flex-none" />
<span className="min-w-0 flex-1">{t('tours.paddle.safety.short')}</span>
<ChevronRight size={14} strokeWidth={2.2} aria-hidden="true" className="flex-none self-center rtl:rotate-180" />
</button>
<PaddleSafetyDialog open={open} onClose={close} />
</>
);
}
@@ -0,0 +1,379 @@
// FE-COMP-PADDLE-TIME-001 to FE-COMP-PADDLE-TIME-015: the time estimate of a paddle tour (PAD-07).
import userEvent from '@testing-library/user-event';
import { paddleLayoutOfPoints, type PaddleLock } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { useSettingsStore } from '../../../store/settingsStore';
import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf';
import { portageLegBetween } from '../planner/tourRouteActions';
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard';
/** What the card writes for a time it cannot give: an en dash. */
const OPEN = '–';
/** The sign of a planned total shorter than the estimate: a minus, not a hyphen. */
const MINUS = '−';
const TIMED_LOCK: PaddleLock = { wait_minutes: 20, transit_minutes: 5 };
const NO_TIMES: PaddleLock = { wait_minutes: null, transit_minutes: null };
function point(id: string, lat: number, extra: Partial<TourPlannerWaypoint> = {}): TourPlannerWaypoint {
return { id, lat, lng: 11, role: 'via', ...extra };
}
interface RouteShape {
/** The lock on the water after the portage. */
lock?: PaddleLock | null;
/** A lock where the portage starts, so the boat is carried round it. */
takeOutLock?: PaddleLock | null;
/** A lock at the final take-out, on the water too. */
endLock?: PaddleLock | null;
portage?: boolean;
}
/**
* Along one meridian, so each stretch is its span of latitude: 8 km of water
* from the put-in, a 500 m portage, then 2 km of water past a lock to the
* final take-out, 10.5 km in all.
*/
function route(shape: RouteShape = {}): TourPlannerWaypoint[] {
const { lock = TIMED_LOCK, takeOutLock = null, endLock = null, portage = true } = shape;
const takeOut = point('take-out', 48.072, { lock: takeOutLock });
const reEntry = point('re-entry', 48.0765);
return [
point('put-in', 48, { role: 'start' }),
portage ? { ...takeOut, fixedLeg: portageLegBetween(takeOut, reEntry) } : takeOut,
reEntry,
point('lock', 48.0855, { lock }),
point('final', 48.0945, { role: 'end', lock: endLock }),
];
}
interface CardSetup {
waypoints?: TourPlannerWaypoint[];
/** The paddling speed in effect: 4 km/h unless told otherwise, null for none. */
speedKmh?: number | null;
portageSpeedKmh?: number | null;
breakMinutes?: number | null;
plannedMinutes?: number | null;
/** False leaves the estimate out, as the planner does for a hike. */
estimate?: boolean;
}
/** A paddle planner as far as the card reads it, its estimate worked out the way the planner works it out. */
function paddlePlanner(setup: CardSetup = {}) {
const base = hikePlannerDefaults();
const waypoints = setup.waypoints ?? route();
const setPortageSpeedKmh = vi.fn();
const paddle = {
...base.paddle,
effectiveSpeedKmh: setup.speedKmh === undefined ? 4 : setup.speedKmh,
portageSpeedKmh: setup.portageSpeedKmh ?? null,
setPortageSpeedKmh,
};
const planner = {
...base,
tourType: 'kayak',
isPaddle: true,
paddle,
waypoints,
paddleEstimate: setup.estimate === false ? null : paddleEstimateOf(waypoints, paddle, false),
breakAdditionalMinutes: setup.breakMinutes ?? null,
plannedDurationMinutes: setup.plannedMinutes ?? null,
} as unknown as TourPlannerController;
return { planner, setPortageSpeedKmh };
}
function card(setup: CardSetup = {}, locked = false) {
return <PaddleTimeCard planner={paddlePlanner(setup).planner} locked={locked} />;
}
/** Renders the card and hands back the planner's carrying speed setter. */
function renderCard(setup: CardSetup = {}, locked = false) {
const { planner, setPortageSpeedKmh } = paddlePlanner(setup);
render(<PaddleTimeCard planner={planner} locked={locked} />);
return setPortageSpeedKmh;
}
/** The block as a recorded track shows it, with the paddling speed set in the water tile. */
function renderBody(paddleSpeed: SpeedControl, locked = false) {
const waypoints = route();
const speeds = { effectiveSpeedKmh: paddleSpeed.value, portageSpeedKmh: null };
const estimate = paddleEstimateOf(waypoints, speeds, false);
render(
<PaddleEstimateBody
estimate={estimate}
layout={paddleLayoutOfPoints(paddlePlanPoints(waypoints))}
breakMinutes={null}
planned={null}
locked={locked}
portageSpeed={{ value: null, startValue: 2, onChange: vi.fn() }}
paddleSpeed={paddleSpeed}
/>
);
}
/** Types into a speed field the way a paddler does: into the field, a value, out of it again. */
function enter(input: HTMLElement, value: string) {
fireEvent.focus(input);
fireEvent.change(input, { target: { value } });
fireEvent.blur(input);
}
const tile = (name: string) => screen.getByTestId(`paddle-time-${name}`);
const estimateText = () => screen.getByTestId('paddle-time-estimate').textContent;
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
});
describe('PaddleTimeCard (PAD-07)', () => {
it('FE-COMP-PADDLE-TIME-001: renders nothing without an estimate or before the tour has two points', () => {
const { container, rerender } = render(card({ estimate: false }));
expect(container).toBeEmptyDOMElement();
rerender(card({ waypoints: route().slice(0, 1) }));
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-PADDLE-TIME-002: heads the card with the estimate, breaks included, and says its values are planning values', () => {
renderCard({ breakMinutes: 30 });
const region = screen.getByRole('region', { name: 'Time estimate' });
expect(within(region).getByText('Planning values')).toBeInTheDocument();
const info = within(region).getByRole('button', { name: 'Planning values' });
expect(info).toHaveAccessibleDescription(/^TREK starts with 4 km\/h on the water/);
expect(within(region).getByText('Estimate')).toBeInTheDocument();
// 200 minutes of water, carrying, getting out and in and the lock, then 30 minutes of breaks.
expect(estimateText()).toBe('3 h 50 min');
expect(screen.queryByText('Incomplete')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-time-planned')).not.toBeInTheDocument();
// The planner sets its paddling speed in the type fields; the card shows it as it is.
expect(screen.queryByRole('textbox', { name: 'Paddling speed' })).not.toBeInTheDocument();
});
it('FE-COMP-PADDLE-TIME-003: breaks the estimate into water, carrying, getting out and in, locks and breaks', () => {
renderCard({ breakMinutes: 30 });
const water = tile('water');
expect(within(water).getByText('Water')).toBeInTheDocument();
expect(within(water).getByText('10 km')).toBeInTheDocument();
expect(within(water).getByText('4 km/h')).toBeInTheDocument();
expect(within(water).getByText('2 h 30 min')).toBeInTheDocument();
const carry = tile('carry');
expect(within(carry).getByText('Carrying')).toBeInTheDocument();
expect(within(carry).getByText('500 m')).toBeInTheDocument();
expect(within(carry).getByText('km/h')).toBeInTheDocument();
// 500 m at the starting 2 km/h.
expect(within(carry).getByText('15 min')).toBeInTheDocument();
const outIn = tile('out-in');
expect(within(outIn).getByText('Out and back in')).toBeInTheDocument();
expect(within(outIn).getByText('1×')).toBeInTheDocument();
expect(within(outIn).getByText('10 min')).toBeInTheDocument();
const locks = tile('locks');
expect(within(locks).getByText('Locks')).toBeInTheDocument();
expect(within(locks).getByText('1×')).toBeInTheDocument();
expect(within(locks).getByText('25 min')).not.toHaveClass('text-warning');
const breaks = tile('breaks');
expect(within(breaks).getByText('Breaks')).toHaveClass('text-content');
expect(within(breaks).getByText('30 min')).toBeInTheDocument();
});
it('FE-COMP-PADDLE-TIME-004: draws the route as a band of its stretches with the lock on it', () => {
renderCard();
const strip = within(screen.getByTestId('paddle-time-card')).getByTestId('paddle-route-strip');
const kinds = Array.from(strip.querySelectorAll('[data-stretch]'), (node) => node.getAttribute('data-stretch'));
expect(kinds).toEqual(['water', 'portage', 'water']);
expect(within(strip).getAllByTestId('paddle-route-strip-lock')).toHaveLength(1);
expect(within(strip).getByText('0 m')).toBeInTheDocument();
expect(within(strip).getByText('10.5 km')).toBeInTheDocument();
});
it('FE-COMP-PADDLE-TIME-005: gives the distances in the unit the user reads', () => {
seedStore(useSettingsStore, { settings: { distance_unit: 'imperial' } });
renderCard();
expect(within(tile('water')).getByText('6.2 mi')).toBeInTheDocument();
expect(within(tile('carry')).getByText('0.3 mi')).toBeInTheDocument();
expect(within(screen.getByTestId('paddle-route-strip')).getByText('6.5 mi')).toBeInTheDocument();
});
it('FE-COMP-PADDLE-TIME-006: a lock without times leaves the estimate incomplete and says so', () => {
const { rerender } = render(card({ waypoints: route({ lock: NO_TIMES }) }));
// What is known still adds up: water, carrying and getting out and in.
expect(estimateText()).toBe('2 h 55 min');
expect(screen.getByText('Incomplete')).toHaveClass('text-warning');
expect(within(tile('locks')).getByText(OPEN)).toHaveClass('text-warning');
expect(screen.getByText('One lock has no times yet.')).toBeInTheDocument();
// Without breaks entered the breaks tile stays open rather than reading zero.
expect(within(tile('breaks')).getByText('Breaks')).toHaveClass('text-content-muted');
expect(within(tile('breaks')).getByText(OPEN)).toBeInTheDocument();
// A wait without its passage counts what is known and still warns.
rerender(card({ waypoints: route({ lock: { wait_minutes: 20, transit_minutes: null } }) }));
expect(estimateText()).toBe('3 h 15 min');
expect(within(tile('locks')).getByText('20 min')).toHaveClass('text-warning');
expect(screen.getByText('One lock has no times yet.')).toBeInTheDocument();
rerender(card({ waypoints: route({ lock: NO_TIMES, endLock: NO_TIMES }) }));
expect(within(tile('locks')).getByText('2×')).toBeInTheDocument();
expect(screen.getByText('2 locks have no times yet.')).toBeInTheDocument();
});
it('FE-COMP-PADDLE-TIME-007: without a paddling speed the water time stays open and the rest still adds up', () => {
const { rerender } = render(card({ speedKmh: null }));
const water = tile('water');
expect(within(water).getByText('10 km')).toBeInTheDocument();
expect(within(water).queryByText(/km\/h/)).not.toBeInTheDocument();
expect(within(water).getByText(OPEN)).toBeInTheDocument();
// Carrying, getting out and in and the lock: 15 + 10 + 25 minutes.
expect(estimateText()).toBe('50 min');
expect(screen.getByText('Incomplete')).toBeInTheDocument();
expect(screen.getByText('Without a paddling speed the time on the water stays open.')).toBeInTheDocument();
// Everything still missing is named in one notice.
rerender(card({ speedKmh: null, waypoints: route({ lock: NO_TIMES }) }));
const both = 'Without a paddling speed the time on the water stays open. One lock has no times yet.';
expect(screen.getByText(both)).toBeInTheDocument();
});
it('FE-COMP-PADDLE-TIME-008: a lock the portage carries round is not counted, and a tour without portage has no carry tiles', () => {
const { rerender } = render(card({ waypoints: route({ takeOutLock: NO_TIMES }) }));
const marks = screen.getAllByTestId('paddle-route-strip-lock');
expect(marks).toHaveLength(2);
expect(marks[0]).toHaveClass('opacity-45');
expect(marks[1]).not.toHaveClass('opacity-45');
expect(within(tile('locks')).getByText('1×')).toBeInTheDocument();
expect(within(tile('locks')).getByText('25 min')).toBeInTheDocument();
// Its missing times are the portage's business, not a gap in the estimate.
expect(screen.queryByText('Incomplete')).not.toBeInTheDocument();
expect(screen.queryByText('One lock has no times yet.')).not.toBeInTheDocument();
rerender(card({ waypoints: route({ portage: false, lock: null }) }));
expect(screen.queryByTestId('paddle-time-carry')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-time-out-in')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-time-locks')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-route-strip-lock')).not.toBeInTheDocument();
expect(within(tile('water')).getByText('10.5 km')).toBeInTheDocument();
expect(within(tile('water')).getByText('2 h 38 min')).toBeInTheDocument();
});
it('FE-COMP-PADDLE-TIME-009: sets the planned total beside the estimate with the gap to it', () => {
const { rerender } = render(card({ breakMinutes: 30, plannedMinutes: 240 }));
const planned = screen.getByTestId('paddle-time-planned');
expect(planned).toHaveTextContent('Your planned total');
expect(within(planned).getByText('4 h')).toBeInTheDocument();
expect(within(planned).getByText('+10 min')).toHaveClass('text-content-secondary');
// A plan shorter than the estimate is the one to look at again.
rerender(card({ breakMinutes: 30, plannedMinutes: 200 }));
const shorter = within(screen.getByTestId('paddle-time-deviation')).getByText(`${MINUS}30 min`);
expect(shorter).toHaveClass('text-warning');
rerender(card({ breakMinutes: 30, plannedMinutes: 230 }));
expect(screen.getByTestId('paddle-time-planned')).toHaveTextContent('3 h 50 min');
expect(screen.queryByTestId('paddle-time-deviation')).not.toBeInTheDocument();
});
it('FE-COMP-PADDLE-TIME-010: offers the starting carrying speed and hands a typed one to the planner on Enter', async () => {
const user = userEvent.setup();
const setPortageSpeedKmh = renderCard();
const input = within(tile('carry')).getByRole('textbox', { name: 'Carrying speed' });
expect(input).toHaveValue('');
expect(input).toHaveAttribute('placeholder', '2');
expect(input).toBeEnabled();
await user.type(input, '2,5{Enter}');
expect(setPortageSpeedKmh).toHaveBeenCalledTimes(1);
expect(setPortageSpeedKmh).toHaveBeenCalledWith(2.5);
expect(input).not.toHaveFocus();
// The field shows the planner's value again, which the stub leaves unset.
expect(input).toHaveValue('');
});
it('FE-COMP-PADDLE-TIME-011: keeps digits and separators, four characters at most, and rounds to a tenth', () => {
const setPortageSpeedKmh = renderCard();
const input = screen.getByRole('textbox', { name: 'Carrying speed' });
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '3a,5' } });
expect(input).toHaveValue('3,5');
fireEvent.blur(input);
expect(setPortageSpeedKmh).toHaveBeenLastCalledWith(3.5);
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '1.2789' } });
expect(input).toHaveValue('1.27');
fireEvent.blur(input);
expect(setPortageSpeedKmh).toHaveBeenLastCalledWith(1.3);
});
it('FE-COMP-PADDLE-TIME-012: ignores a carrying speed out of range or malformed, and an emptied field returns to the starting value', () => {
const setPortageSpeedKmh = renderCard({ portageSpeedKmh: 3 });
const input = screen.getByRole('textbox', { name: 'Carrying speed' });
expect(input).toHaveValue('3');
// 500 m at 3 km/h.
expect(within(tile('carry')).getByText('10 min')).toBeInTheDocument();
for (const typed of ['12', '0,4', '1.2.']) enter(input, typed);
// Leaving the field without having been in it changes nothing either.
fireEvent.blur(input);
expect(setPortageSpeedKmh).not.toHaveBeenCalled();
expect(input).toHaveValue('3');
enter(input, '');
expect(setPortageSpeedKmh).toHaveBeenCalledWith(null);
});
it('FE-COMP-PADDLE-TIME-013: a locked planner shows the carrying speed without taking a new one', () => {
renderCard({ portageSpeedKmh: 3 }, true);
const input = screen.getByRole('textbox', { name: 'Carrying speed' });
expect(input).toHaveValue('3');
expect(input).toBeDisabled();
});
});
describe('PaddleEstimateBody (PAD-07)', () => {
it('FE-COMP-PADDLE-TIME-014: a recorded track types its paddling speed into the water tile, up to 20 km/h', () => {
const onChange = vi.fn();
renderBody({ value: 5, startValue: 4, onChange });
const water = tile('water');
const input = within(water).getByRole('textbox', { name: 'Paddling speed' });
expect(input).toHaveValue('5');
expect(input).toHaveAttribute('placeholder', '4');
expect(within(water).queryByText('5 km/h')).not.toBeInTheDocument();
// 10 km at 5 km/h.
expect(within(water).getByText('2 h')).toBeInTheDocument();
enter(input, '25');
expect(onChange).not.toHaveBeenCalled();
// Faster than a boat is carried, but a speed on the water.
enter(input, '12');
expect(onChange).toHaveBeenCalledWith(12);
});
it('FE-COMP-PADDLE-TIME-015: without a starting paddling speed the field shows a dash, and a locked track takes none', () => {
renderBody({ value: null, startValue: null, onChange: vi.fn() }, true);
const input = screen.getByRole('textbox', { name: 'Paddling speed' });
expect(input).toHaveValue('');
expect(input).toHaveAttribute('placeholder', OPEN);
expect(input).toBeDisabled();
expect(screen.getByText('Without a paddling speed the time on the water stays open.')).toBeInTheDocument();
});
});
@@ -0,0 +1,434 @@
import {
DEFAULT_PORTAGE_SPEED_KMH,
MAX_PADDLE_SPEED_KMH,
MAX_PORTAGE_SPEED_KMH,
paddleLayoutOfPoints,
type PaddleLayout,
type PaddleTimeEstimate,
} from '@trek/shared';
import { AlertTriangle, Timer } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { paddlePlanPoints } from '../planner/paddlePlanOf';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { InfoDot, RailCard, TourNotice } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { PaddleGlyph } from './PaddleGlyph';
import { PORTAGE_CSS_COLOR } from './paddlePaint';
import { PaddleRouteStrip, WATER_COLOR } from './PaddleRouteStrip';
const minutes = (value: number | null) => (value === null ? '–' : formatPlannedTourDuration(value));
/** A speed in km/h typed in place, decimals with either separator; the starting value shows until one is typed. */
function KmhChip({
label,
value,
placeholder,
max,
disabled,
onChange,
testId,
}: {
label: string;
value: number | null;
/** The starting value, shown while none is typed; null shows a dash. */
placeholder: number | null;
/** The highest speed the field takes, in km/h. */
max: number;
disabled: boolean;
onChange: (kmh: number | null) => void;
testId: string;
}) {
const { locale } = useTranslation();
const [draft, setDraft] = useState<string | null>(null);
const shown = (kmh: number) => kmh.toLocaleString(locale, { maximumFractionDigits: 1 });
const commit = (text: string) => {
const trimmed = text.trim().replace(',', '.');
if (trimmed === '') return onChange(null);
const kmh = Number(trimmed);
if (Number.isFinite(kmh) && kmh >= 0.5 && kmh <= max) onChange(Math.round(kmh * 10) / 10);
};
return (
<label className="inline-flex items-center gap-0.5 rounded-sm focus-within:outline focus-within:outline-1 focus-within:outline-accent">
<input
data-testid={testId}
type="text"
inputMode="decimal"
aria-label={label}
disabled={disabled}
value={draft ?? (value === null ? '' : shown(value))}
placeholder={placeholder === null ? '–' : shown(placeholder)}
onFocus={() => setDraft(value === null ? '' : shown(value))}
onChange={(event) => setDraft(event.target.value.replace(/[^\d.,]/g, '').slice(0, 4))}
onBlur={() => {
if (draft !== null) commit(draft);
setDraft(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
className="w-[3ch] min-w-0 border-b border-dashed border-edge bg-transparent text-end font-geist tabular-nums text-content outline-none placeholder:text-content-secondary"
style={fs(10.5)}
/>
<span className="text-content-faint" style={fs(10.5)}>
km/h
</span>
</label>
);
}
/**
* Measures that belong together (a distance and the speed it is covered at)
* as one white badge within the grey tile, its parts split by a hairline, so
* a narrow rail keeps them on the tile's first line.
*/
function MeasureGroup({ parts }: { parts: ReactNode[] }) {
return (
<span
className="inline-flex h-[20px] items-center rounded-full bg-surface-card px-1.5 font-geist tabular-nums text-content-secondary ring-1 ring-inset ring-edge-faint"
style={fs(10.5)}
>
{parts.map((part, index) => (
// The parts are fixed per tile; their order is their identity.
<span key={index} className={index > 0 ? 'ms-1.5 border-s border-edge-faint ps-1.5' : ''}>
{part}
</span>
))}
</span>
);
}
/** A measure inside a part's tile: a white badge within the grey one. */
function MeasureBadge({ children }: { children: ReactNode }) {
return (
<span
className="inline-flex h-[20px] items-center rounded-full bg-surface-card px-1.5 font-geist tabular-nums text-content-secondary ring-1 ring-inset ring-edge-faint"
style={fs(10.5)}
>
{children}
</span>
);
}
/**
* One part of the estimate as a tile: its glyph in the colour the band gives
* it, its name and measures as badges within, and its time as the white badge
* at the end. The tile wraps in a narrow rail rather than cutting anything.
*/
function PartTile({
icon,
color,
label,
measures,
time,
muted = false,
warn = false,
testId,
}: {
icon: ReactNode;
/** The band's colour for this part, so tile and band read as one. */
color?: string;
label: string;
measures?: ReactNode;
time: string;
muted?: boolean;
/** A time that leaves something out: drawn in the warning tone. */
warn?: boolean;
testId?: string;
}) {
return (
<div
data-testid={testId}
className="flex min-w-0 flex-wrap items-center gap-1.5 rounded-[12px] bg-surface-secondary py-1.5 pe-1.5 ps-1.5"
>
<span
className="grid h-[22px] w-[22px] flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm"
style={color ? { color } : undefined}
>
{icon}
</span>
<span
className={`min-w-0 truncate font-semibold ${muted ? 'text-content-muted' : 'text-content'}`}
style={fs(12, 'body')}
>
{label}
</span>
{measures}
<span
className={`ms-auto inline-flex h-[22px] flex-none items-center rounded-full bg-surface-card px-2 font-geist font-semibold tabular-nums shadow-sm ${warn ? 'text-warning' : 'text-content'}`}
style={fs(11.5)}
>
{time}
</span>
</div>
);
}
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** What of the estimate is still missing, said plainly, never folded into a total that looks whole. */
function missingParts(estimate: PaddleTimeEstimate, t: Translate): string[] {
const parts: string[] = [];
if (estimate.waterMinutes === null) parts.push(t('tours.paddle.time.noSpeed'));
if (estimate.locksMissingTime > 0)
parts.push(t('tours.paddle.time.locksMissing', { count: estimate.locksMissingTime }));
return parts;
}
/** The estimate as the card's headline, with the paddler's own planned total and the gap to it beside it. */
function EstimateHead({
total,
complete,
planned,
t,
}: {
total: number;
complete: boolean;
planned: number | null;
t: Translate;
}) {
const deviation = planned === null ? null : planned - total;
return (
<div className="flex min-w-0 flex-wrap items-end gap-x-3 gap-y-1.5">
<div className="min-w-0 flex-1">
<span className="block text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.time.estimate')}
</span>
<span className="flex items-center gap-2">
<span
data-testid="paddle-time-estimate"
className="font-geist font-semibold tabular-nums text-content"
style={fs(20)}
>
{formatPlannedTourDuration(total)}
</span>
{!complete && <SoftPill tone="warning">{t('tours.paddle.time.incomplete')}</SoftPill>}
</span>
</div>
{planned !== null && deviation !== null && (
<span
data-testid="paddle-time-planned"
className="inline-flex h-[24px] flex-none items-center gap-1 rounded-full bg-surface-secondary pe-1 ps-2 text-content-muted"
style={fs(11, 'body')}
>
{t('tours.paddle.time.plannedOwn')}
<MeasureBadge>{formatPlannedTourDuration(planned)}</MeasureBadge>
{deviation !== 0 && (
<span data-testid="paddle-time-deviation">
<SoftPill tone={deviation < 0 ? 'warning' : 'neutral'}>
{`${deviation > 0 ? '+' : '−'}${formatPlannedTourDuration(Math.abs(deviation))}`}
</SoftPill>
</span>
)}
</span>
)}
</div>
);
}
/** A speed the rail lets the paddler set, with the starting value it falls back to. */
export interface SpeedControl {
value: number | null;
startValue: number | null;
onChange: (kmh: number | null) => void;
}
/** The tiles of the parts, water first, then what a portage and a lock add, then the breaks. */
function PartTiles({
estimate,
breakMinutes,
locked,
portageSpeed,
paddleSpeed,
}: {
estimate: PaddleTimeEstimate;
breakMinutes: number | null;
locked: boolean;
portageSpeed: SpeedControl;
/** Set here where the tour has no boat form of its own (a recorded track); else shown as it is. */
paddleSpeed?: SpeedControl;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const km = (m: number) => formatDistance(m / 1000, unit);
const speed = estimate.paddleSpeedKmh;
const water = paddleSpeed ? (
<KmhChip
key="speed"
testId="paddle-time-water-speed"
label={t('tours.paddle.speed')}
value={paddleSpeed.value}
placeholder={paddleSpeed.startValue}
max={MAX_PADDLE_SPEED_KMH}
disabled={locked}
onChange={paddleSpeed.onChange}
/>
) : speed !== null ? (
`${speed.toLocaleString()} km/h`
) : null;
return (
<div className="flex flex-col gap-1.5">
<PartTile
testId="paddle-time-water"
icon={<PaddleGlyph kind="put_in" size={12} strokeWidth={2.4} />}
color={WATER_COLOR}
label={t('tours.paddle.time.waterShort')}
measures={<MeasureGroup parts={water === null ? [km(estimate.waterM)] : [km(estimate.waterM), water]} />}
time={minutes(estimate.waterMinutes)}
/>
{estimate.portageCount > 0 && (
<PartTile
testId="paddle-time-carry"
icon={<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />}
color={PORTAGE_CSS_COLOR}
label={t('tours.paddle.time.carry')}
measures={
<MeasureGroup
parts={[
km(estimate.landM),
<KmhChip
key="speed"
testId="paddle-time-carry-speed"
label={t('tours.paddle.time.carrySpeed')}
value={portageSpeed.value}
placeholder={portageSpeed.startValue}
max={MAX_PORTAGE_SPEED_KMH}
disabled={locked}
onChange={portageSpeed.onChange}
/>,
]}
/>
}
time={minutes(estimate.carryMinutes)}
/>
)}
{estimate.portageCount > 0 && (
<PartTile
testId="paddle-time-out-in"
icon={<PaddleGlyph kind="take_out" size={12} strokeWidth={2.4} />}
color={PORTAGE_CSS_COLOR}
label={t('tours.paddle.time.outIn')}
measures={<MeasureBadge>{`${estimate.portageCount}×`}</MeasureBadge>}
time={minutes(estimate.portageExtraMinutes)}
/>
)}
{estimate.locksCounted > 0 && (
<PartTile
testId="paddle-time-locks"
icon={<PaddleGlyph kind="lock" size={12} strokeWidth={2.4} />}
label={t('tours.paddle.time.locks')}
measures={<MeasureBadge>{`${estimate.locksCounted}×`}</MeasureBadge>}
time={estimate.locksMissingTime > 0 && estimate.lockMinutes === 0 ? '–' : minutes(estimate.lockMinutes)}
warn={estimate.locksMissingTime > 0}
/>
)}
<PartTile
testId="paddle-time-breaks"
icon={<Timer size={12} strokeWidth={2.4} />}
label={t('tours.planner.breaksShort')}
time={minutes(breakMinutes)}
muted={breakMinutes === null}
/>
</div>
);
}
/**
* The estimate of a paddle tour as one block: the total as its headline beside
* the paddler's own planned total, the band of its stretches, the tiles of its
* parts and what is still missing. The planner and a recorded track show it alike.
*/
export function PaddleEstimateBody({
estimate,
layout,
breakMinutes,
planned,
locked,
portageSpeed,
paddleSpeed,
}: {
estimate: PaddleTimeEstimate;
layout: PaddleLayout;
breakMinutes: number | null;
planned: number | null;
locked: boolean;
portageSpeed: SpeedControl;
paddleSpeed?: SpeedControl;
}) {
const { t } = useTranslation();
const missing = missingParts(estimate, t);
return (
<div data-testid="paddle-time-card" className="flex flex-col gap-3">
<EstimateHead
total={estimate.estimateMinutes + (breakMinutes ?? 0)}
complete={estimate.complete}
planned={planned}
t={t}
/>
<PaddleRouteStrip layout={layout} />
<PartTiles
estimate={estimate}
breakMinutes={breakMinutes}
locked={locked}
portageSpeed={portageSpeed}
paddleSpeed={paddleSpeed}
/>
{missing.length > 0 && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{missing.join(' ')}
</TourNotice>
)}
</div>
);
}
/** The card's head: its title, and that its values are planning values to adjust. */
export function PaddleTimeCardShell({ children }: { children: ReactNode }) {
const { t } = useTranslation();
return (
<RailCard
icon={<Timer size={14} strokeWidth={2} />}
title={t('tours.paddle.time.title')}
aside={
<span className="flex flex-none items-center gap-1">
<SoftPill tone="info">{t('tours.paddle.time.assumption')}</SoftPill>
<InfoDot label={t('tours.paddle.time.assumption')} description={t('tours.paddle.time.assumptionInfo')} />
</span>
}
>
{children}
</RailCard>
);
}
/**
* PAD-07: how the time of a paddle tour comes about. Water at the paddling
* speed, portages at the carrying speed with their time out and in, locks at
* the times entered for them, the breaks; their sum is the estimate, set
* beside a total the paddler planned themselves. Every starting value says it
* is one, and what is unknown is named rather than counted as nothing.
*/
export function PaddleTimeCard({ planner, locked }: { planner: TourPlannerController; locked: boolean }) {
const estimate = planner.paddleEstimate;
if (!estimate || planner.waypoints.length < 2) return null;
return (
<PaddleTimeCardShell>
<PaddleEstimateBody
estimate={estimate}
layout={paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints))}
breakMinutes={planner.breakAdditionalMinutes}
planned={planner.plannedDurationMinutes}
locked={locked}
portageSpeed={{
value: planner.paddle.portageSpeedKmh,
startValue: DEFAULT_PORTAGE_SPEED_KMH,
onChange: planner.paddle.setPortageSpeedKmh,
}}
/>
</PaddleTimeCardShell>
);
}
@@ -0,0 +1,327 @@
import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared';
import { ChevronDown, X } from 'lucide-react';
import { useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { fs } from '../../shared/DialogShell';
import { PaddleGlyph } from './PaddleGlyph';
import { PORTAGE_CSS_COLOR } from './paddlePaint';
const ROLE_GLYPH = { put_in: 'put_in', take_out: 'take_out', re_entry: 'put_in', final_take_out: 'take_out' } as const;
/** Put-in, take-out, re-entry or final take-out, as the badge of a waypoint row of a paddle tour. */
export function PaddleRolePill({ role }: { role: PaddleRole }) {
const { t } = useTranslation();
const land = role === 'take_out' || role === 're_entry';
return (
<span
data-paddle-role={role}
className="inline-flex h-[20px] min-w-0 flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-secondary px-2 font-geist leading-none text-content-secondary"
style={fs(10.5)}
>
<PaddleGlyph
kind={ROLE_GLYPH[role]}
size={11}
strokeWidth={2.4}
style={land ? { color: PORTAGE_CSS_COLOR } : undefined}
/>
{t(`tours.paddle.role.${role}`)}
</span>
);
}
/**
* A number of minutes typed in place: a value or nothing. Text that is no
* whole number of minutes in range is dropped on leaving the field, and the
* value before it stays.
*/
function MinutesInput({
label,
value,
placeholder,
disabled,
onChange,
testId,
}: {
label: string;
value: number | null;
/** What shows while there is no value: the starting value, or a dash. */
placeholder: string;
disabled: boolean;
onChange: (minutes: number | null) => void;
testId?: string;
}) {
const [draft, setDraft] = useState<string | null>(null);
const commit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (trimmed === '') return onChange(null);
const minutes = Number(trimmed);
if (Number.isInteger(minutes) && minutes >= 0 && minutes <= MAX_PADDLE_STOP_MINUTES) onChange(minutes);
},
[onChange]
);
return (
<label className="inline-flex h-[26px] flex-none items-center gap-1 rounded-full border border-edge bg-surface-card pe-2.5 ps-2.5 transition-colors focus-within:border-accent">
<input
data-testid={testId}
type="text"
inputMode="numeric"
aria-label={label}
disabled={disabled}
value={draft ?? (value === null ? '' : String(value))}
placeholder={placeholder}
onFocus={() => setDraft(value === null ? '' : String(value))}
onChange={(event) => setDraft(event.target.value.replace(/[^\d]/g, '').slice(0, 3))}
onBlur={() => {
if (draft !== null) commit(draft);
setDraft(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
if (event.key === 'Escape') {
setDraft(null);
event.currentTarget.blur();
}
}}
className="w-[3ch] min-w-0 bg-transparent text-end font-geist font-semibold tabular-nums text-content outline-none placeholder:font-normal placeholder:text-content-faint"
style={fs(12)}
/>
<span className="text-content-faint" style={fs(11)}>
min
</span>
</label>
);
}
/** One labelled value of an opened stop, its input on the right. */
function StopField({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1">
<span className="block truncate text-content" style={fs(12, 'body')}>
{label}
</span>
{hint && (
<span className="block truncate text-content-faint" style={fs(10.5, 'body')}>
{hint}
</span>
)}
</span>
{children}
</div>
);
}
/** A value of a stop as the closed card shows it: quiet, or in the warning tone when it is missing. */
function StopPill({ children, missing = false }: { children: ReactNode; missing?: boolean }) {
return (
<span
className={`flex-none rounded-full px-1.5 font-geist tabular-nums leading-[18px] ${missing ? 'bg-warning-soft text-warning' : 'bg-surface-secondary text-content-secondary'}`}
style={fs(10.5)}
>
{children}
</span>
);
}
/** The quiet way to take a stop off again, at the foot of its opened card. */
function RemoveStop({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<button
type="button"
onClick={onRemove}
className="flex items-center gap-1 self-start rounded-full px-1 text-danger transition-opacity hover:opacity-80"
style={fs(11.5, 'body')}
>
<X size={12} strokeWidth={2.4} />
{label}
</button>
);
}
/**
* A portage or a lock under its waypoint, in the shape of the via ferrata leg:
* a round glyph, the name and its values as pills. A click opens it to change
* those values in labelled fields, and closes it again.
*/
function StopCard({
testId,
glyph,
color,
title,
pills,
muted = false,
children,
}: {
testId: string;
glyph: ReactNode;
/** The colour of its ring and its round glyph: the portage brown, or the quiet default. */
color?: string;
title: string;
pills: ReactNode;
muted?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<li data-testid={testId} className="flex min-w-0 flex-col gap-1 py-0.5 ps-[34px]">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
className={`flex h-[28px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1.5 ps-1 text-start shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover ${muted ? 'opacity-60' : ''}`}
style={color ? { boxShadow: `inset 0 0 0 1px ${color}` } : undefined}
>
<span
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full bg-content-secondary text-surface-card"
style={color ? { background: color } : undefined}
>
{glyph}
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
{title}
</span>
{pills}
<ChevronDown
size={13}
strokeWidth={2.2}
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-card px-2.5 py-2">
{children}
</div>
)}
</li>
);
}
/**
* A portage under the point it starts at, in the earth brown the map draws it
* in: its length on land and the time for getting out, unloading and back in
* (the starting value until one is typed).
*/
export function PortageLegRow({
lengthM,
extraMinutes,
disabled,
onExtraChange,
position,
onRemove,
}: {
lengthM: number;
extraMinutes: number | null;
disabled: boolean;
onExtraChange: (minutes: number | null) => void;
/** Where it lies on a recorded track ("at 4.2 km"); a planned tour shows it under its waypoint instead. */
position?: string;
/** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */
onRemove?: () => void;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
const extra = extraMinutes ?? DEFAULT_PORTAGE_EXTRA_MINUTES;
return (
<StopCard
testId="tour-planner-portage-leg"
glyph={<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />}
color={PORTAGE_CSS_COLOR}
title={t('tours.paddle.portage')}
pills={
<>
{position && <StopPill>{position}</StopPill>}
<StopPill>{formatDistance(lengthM / 1000, unit ?? 'metric')}</StopPill>
<StopPill>{`+${extra} min`}</StopPill>
</>
}
>
<p className="m-0 leading-snug text-content-muted" style={fs(11.5, 'body')}>
{t('tours.paddle.portageInfo')}
</p>
{onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />}
<StopField
label={t('tours.paddle.portageExtra')}
hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined}
>
<MinutesInput
testId="tour-planner-portage-extra"
label={t('tours.paddle.portageExtra')}
value={extraMinutes}
placeholder={String(DEFAULT_PORTAGE_EXTRA_MINUTES)}
disabled={disabled}
onChange={onExtraChange}
/>
</StopField>
</StopCard>
);
}
/**
* A lock at a waypoint with the wait and the passage expected there. A time
* not typed is missing, never zero, and the closed card says so; a lock a
* portage carries round is dimmed and its times do not count.
*/
export function LockRow({
lock,
bypassed,
disabled,
onChange,
position,
}: {
lock: PaddleLock;
bypassed: boolean;
disabled: boolean;
onChange: (lock: PaddleLock | null) => void;
/** Where it lies on a recorded track ("at 4.2 km"). */
position?: string;
}) {
const { t } = useTranslation();
const missing = lock.wait_minutes === null || lock.transit_minutes === null;
const known = (lock.wait_minutes ?? 0) + (lock.transit_minutes ?? 0);
const lockPill = bypassed ? (
<StopPill>{t('tours.paddle.lockBypassed')}</StopPill>
) : missing ? (
<StopPill missing>{t('tours.paddle.timeMissing')}</StopPill>
) : (
<StopPill>{`${known} min`}</StopPill>
);
return (
<StopCard
testId="tour-planner-lock"
glyph={<PaddleGlyph kind="lock" size={12} strokeWidth={2.4} />}
title={t('tours.paddle.lock')}
muted={bypassed}
pills={
<>
{position && <StopPill>{position}</StopPill>}
{lockPill}
</>
}
>
<StopField label={t('tours.paddle.lockWait')}>
<MinutesInput
testId="tour-planner-lock-wait"
label={t('tours.paddle.lockWait')}
value={lock.wait_minutes}
placeholder="–"
disabled={disabled}
onChange={(minutes) => onChange({ ...lock, wait_minutes: minutes })}
/>
</StopField>
<StopField label={t('tours.paddle.lockTransit')}>
<MinutesInput
testId="tour-planner-lock-transit"
label={t('tours.paddle.lockTransit')}
value={lock.transit_minutes}
placeholder="–"
disabled={disabled}
onChange={(minutes) => onChange({ ...lock, transit_minutes: minutes })}
/>
</StopField>
{!disabled && <RemoveStop label={t('tours.paddle.lockRemove')} onRemove={() => onChange(null)} />}
</StopCard>
);
}
@@ -0,0 +1,370 @@
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-016: the wind card of a paddle tour (PAD-08) and the section that feeds it.
// FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Day, WindForecastResponse, WindHour } from '@trek/shared';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { formatDate } from '../../../utils/formatters';
import type { TourDay } from '../tourDays';
import { FigureBadge } from './FigureBadge';
import { PaddleWindCard, PaddleWindSection } from './PaddleWindCard';
import type { TourWind, TourWindState } from './useTourWind';
const hook = vi.hoisted(() => ({
useTourWind: vi.fn<(placeId: number | null, start: { lat: number; lng: number } | null) => TourWind>(),
}));
vi.mock('./useTourWind', () => ({ useTourWind: hook.useTourWind }));
/** The dash a figure the source left out reads as, never a zero. */
const NONE = '–';
/** The notices as shared/src/i18n/en/tours.ts words them. */
const SAYS = {
noDay: 'Put the tour on a day of the trip to see the wind forecast for that day.',
noDate: 'This day of the trip has no date, so there is no forecast for it.',
noPlace: 'The tour has no start point yet to ask the forecast for.',
offline: 'Without a connection there is no wind forecast.',
error: 'The wind forecast cannot be fetched right now.',
past: 'This day has passed. A forecast exists only for days to come.',
};
const HINTS = [
'Headwind costs time and strength.',
'Crosswind pushes the boat off course.',
'Open crossings are more exposed than the shore.',
'Gusts are stronger than the mean and come suddenly.',
'No figure is an all-clear. Check the conditions on site.',
];
const FETCHED_AT = '2026-07-07T09:45:00Z';
const FETCHED_CLOCK = new Date(FETCHED_AT).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
/** When the forecast was asked, with the spaces the DOM queries read (ICU puts a narrow one before AM). */
const FETCHED_TIME = FETCHED_CLOCK.replace(/\s/g, ' ');
const dayOf = (id: number, date: string | null): Day => ({ id, trip_id: 3, date });
const JULY_8: TourDay = { day: dayOf(11, '2026-07-08'), index: 1 };
const JULY_9: TourDay = { day: dayOf(12, '2026-07-09'), index: 3 };
const UNDATED: TourDay = { day: dayOf(13, null), index: 2 };
const hourOf = (hour: number, speed: number | null, gusts: number | null, direction: number | null): WindHour => ({
hour,
speed_kmh: speed,
gusts_kmh: gusts,
direction_deg: direction,
});
/** A day on the water: an hour either side of the strip, a calm one and one the source left out. */
const HOURS: WindHour[] = [
hourOf(5, 30, 50, 90),
hourOf(6, 8, 14, 200),
hourOf(7, 0, 0, 0),
hourOf(9, null, null, null),
hourOf(10, 10, null, null),
hourOf(12, 20, 40, 270),
hourOf(21, 4, 6, 180),
hourOf(22, 60, 80, 90),
];
function forecastOf(overrides: Partial<WindForecastResponse> = {}): WindForecastResponse {
return {
status: 'forecast',
date: '2026-07-08',
lat: 47.05,
lng: 8.31,
timezone: 'Europe/Zurich',
source: 'Open-Meteo',
fetched_at: FETCHED_AT,
horizon_days: 16,
hours: [],
day: { speed_max_kmh: 18, gusts_max_kmh: 32, direction_dominant_deg: 225 },
...overrides,
};
}
const ready = (forecast: WindForecastResponse): TourWindState => ({ kind: 'ready', forecast });
const retry = vi.fn<TourWind['retry']>();
const chooseDay = vi.fn<TourWind['chooseDay']>();
/** What useTourWind hands the card: a state, the tour's days and the one shown. */
function windOf(
state: TourWindState,
tourDays: TourDay[] = [JULY_8],
day: TourDay | null = tourDays[0] ?? null
): TourWind {
return { state, day, tourDays, chooseDay, retry };
}
const renderCard = (wind: TourWind) => render(<PaddleWindCard wind={wind} />);
/** A day as the card names it, with the spaces the DOM queries read. */
function dateLabel(date: string): string {
const label = formatDate(date, 'en-US');
if (!label) throw new Error(`no label for ${date}`);
return label.replace(/\s/g, ' ');
}
/** What an element's tooltip says once it is hovered; the hover ends before it returns. */
async function tooltipOf(element: HTMLElement): Promise<string | null> {
fireEvent.mouseEnter(element);
const text = (await screen.findByRole('tooltip')).textContent;
fireEvent.mouseLeave(element);
return text;
}
/** The column of one hour in the strip, the element its tooltip hangs on. */
function hourColumn(hour: number): HTMLElement {
const column = within(screen.getByTestId('paddle-wind-hours')).getByText(String(hour)).parentElement;
if (!column) throw new Error(`no column for ${hour}:00`);
return column;
}
/** The height of a column's bar for the mean or the gusts; undefined when the column has none. */
function barHeight(column: HTMLElement, bar: 'mean' | 'gusts'): string | undefined {
return column.querySelector<HTMLElement>(bar === 'mean' ? '.bg-info' : '.bg-info-soft')?.style.height;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('PaddleWindCard', () => {
it('FE-PADDLE-WIND-001: a tour on no day asks to be put on one, names no day and still offers the hints', () => {
renderCard(windOf({ kind: 'no_day' }, [], null));
const card = screen.getByRole('region', { name: 'Wind' });
expect(within(card).getByText(SAYS.noDay)).toBeInTheDocument();
expect(within(card).queryByText(/^Day \d+$/)).not.toBeInTheDocument();
expect(within(card).queryByTestId('paddle-wind-mean')).not.toBeInTheDocument();
expect(within(card).getByRole('button', { name: 'What wind means for paddling' })).toBeInTheDocument();
});
it('FE-PADDLE-WIND-002: a day without a date says there is no forecast for it and is named by its number', () => {
renderCard(windOf({ kind: 'no_date' }, [UNDATED]));
expect(screen.getByText(SAYS.noDate)).toBeInTheDocument();
expect(screen.getByText('Day 3')).toBeInTheDocument();
// One day is a label, not a choice.
expect(screen.queryByRole('button', { name: 'Day 3' })).not.toBeInTheDocument();
});
it('FE-PADDLE-WIND-003: a tour without a start point says so, under the date of its day', () => {
renderCard(windOf({ kind: 'no_place' }));
expect(screen.getByText(SAYS.noPlace)).toBeInTheDocument();
expect(screen.getByText(dateLabel('2026-07-08'))).toBeInTheDocument();
});
it('FE-PADDLE-WIND-004: while the forecast loads it holds a placeholder and no figures', () => {
renderCard(windOf({ kind: 'loading' }));
expect(screen.getByTestId('paddle-wind-loading')).toBeInTheDocument();
expect(screen.queryByTestId('paddle-wind-mean')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-wind-hours')).not.toBeInTheDocument();
});
it('FE-PADDLE-WIND-005: offline it says there is no forecast without a connection and offers no retry', () => {
renderCard(windOf({ kind: 'offline' }));
expect(screen.getByText(SAYS.offline)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-wind-loading')).not.toBeInTheDocument();
});
it('FE-PADDLE-WIND-006: a failed request is a warning whose Try again asks once more', () => {
renderCard(windOf({ kind: 'error' }));
const notice = screen.getByText(SAYS.error);
expect(notice.closest('.bg-warning-soft')).not.toBeNull();
fireEvent.click(within(notice).getByRole('button', { name: 'Try again' }));
expect(retry).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-WIND-007: a day already gone says a forecast exists only for days to come and shows no figures', () => {
renderCard(windOf(ready(forecastOf({ status: 'past', timezone: null, day: null }))));
expect(screen.getByText(SAYS.past)).toBeInTheDocument();
expect(screen.queryByTestId('paddle-wind-mean')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-wind-hours')).not.toBeInTheDocument();
});
it('FE-PADDLE-WIND-008: a day beyond the forecast names the first day its forecast can be had', () => {
const later = forecastOf({ status: 'beyond_horizon', date: '2027-03-20', timezone: null, day: null });
renderCard(windOf(ready(later)));
// Sixteen days, today included, reach the 20th from the 5th on.
const notice = `No forecast for this day yet. It comes from ${dateLabel('2027-03-05')}.`;
expect(screen.getByText(notice)).toBeInTheDocument();
expect(screen.queryByTestId('paddle-wind-mean')).not.toBeInTheDocument();
});
it('FE-PADDLE-WIND-009: a forecast shows the direction, the mean and the gusts of the day, the legend and its source', async () => {
renderCard(windOf(ready(forecastOf())));
const direction = screen.getByTestId('paddle-wind-direction');
expect(direction).toHaveTextContent(/^SW$/);
// From the south west, so the arrow turns half round and points the way the wind blows.
expect(direction.querySelector('svg')?.style.transform).toBe('rotate(405deg)');
const mean = screen.getByTestId('paddle-wind-mean');
expect(within(mean).getByText('Mean')).toBeInTheDocument();
expect(within(mean).getByText('18 km/h')).toBeInTheDocument();
const gusts = screen.getByTestId('paddle-wind-gusts');
expect(within(gusts).getByText('Gusts')).toBeInTheDocument();
expect(within(gusts).getByText('32 km/h')).toBeInTheDocument();
expect(screen.getByText('Mean wind')).toBeInTheDocument();
expect(screen.getByText(`Open-Meteo, ${FETCHED_TIME}, forecast at the start`)).toBeInTheDocument();
expect(screen.queryByTestId('paddle-wind-loading')).not.toBeInTheDocument();
expect(await tooltipOf(direction)).toBe('Wind from SW, the direction it blows from most of the day');
expect(await tooltipOf(mean)).toBe('The strongest hourly mean wind of the day');
expect(await tooltipOf(gusts)).toBe('The strongest gust of the day');
});
it('FE-PADDLE-WIND-010: the hours from 6 to 21 are columns, scaled to the strongest gust among them', async () => {
renderCard(windOf(ready(forecastOf({ hours: HOURS }))));
const strip = screen.getByTestId('paddle-wind-hours');
expect(Array.from(strip.children, (column) => column.textContent)).toEqual(['6', '7', '9', '10', '12', '21']);
// The 40 km/h gusts at noon set the scale; the 80 at 22:00 lie outside the strip and do not.
const noon = hourColumn(12);
expect(barHeight(noon, 'mean')).toBe('50%');
expect(barHeight(noon, 'gusts')).toBe('100%');
// From the west, so the arrow points east.
expect(noon.querySelector('svg')?.style.transform).toBe('rotate(450deg)');
expect(await tooltipOf(noon)).toBe('12:00: 20 km/h, gusts 40 km/h, from W');
// A mean without gusts or a direction draws its bar alone and says what it lacks.
const meanOnly = hourColumn(10);
expect(barHeight(meanOnly, 'mean')).toBe('25%');
expect(barHeight(meanOnly, 'gusts')).toBeUndefined();
expect(meanOnly.querySelector('svg')).toBeNull();
expect(await tooltipOf(meanOnly)).toBe(`10:00: 10 km/h, gusts ${NONE} km/h, from ${NONE}`);
});
it('FE-PADDLE-WIND-011: an hour the source left out stays an empty column, while a calm hour is a figure', async () => {
renderCard(windOf(ready(forecastOf({ hours: HOURS }))));
const unknown = hourColumn(9);
expect(barHeight(unknown, 'mean')).toBeUndefined();
expect(barHeight(unknown, 'gusts')).toBeUndefined();
expect(unknown.querySelector('.border-dashed')).not.toBeNull();
expect(unknown.querySelector('svg')).toBeNull();
expect(await tooltipOf(unknown)).toBe('9:00: no value');
// 0 km/h from the north is a measured calm: a sliver of a bar and an arrow, no dashed outline.
const calm = hourColumn(7);
expect(barHeight(calm, 'mean')).toBe('4%');
expect(barHeight(calm, 'gusts')).toBe('4%');
expect(calm.querySelector('.border-dashed')).toBeNull();
expect(calm.querySelector('svg')?.style.transform).toBe('rotate(180deg)');
expect(await tooltipOf(calm)).toBe('7:00: 0 km/h, gusts 0 km/h, from N');
});
it('FE-PADDLE-WIND-012: a light day is drawn against 10 km/h at least, so a breeze never fills its column', () => {
renderCard(windOf(ready(forecastOf({ hours: [hourOf(8, 5, null, 90)] }))));
expect(barHeight(hourColumn(8), 'mean')).toBe('50%');
});
it('FE-PADDLE-WIND-013: a day figure the source left out reads as a dash, never as calm; a real calm reads 0 km/h', () => {
const blank = forecastOf({
day: { speed_max_kmh: null, gusts_max_kmh: null, direction_dominant_deg: null },
hours: [hourOf(9, null, null, null)],
});
const { rerender } = renderCard(windOf(ready(blank)));
expect(within(screen.getByTestId('paddle-wind-mean')).getByText(NONE)).toBeInTheDocument();
expect(within(screen.getByTestId('paddle-wind-gusts')).getByText(NONE)).toBeInTheDocument();
expect(screen.queryByTestId('paddle-wind-direction')).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-wind-card')).not.toHaveTextContent('km/h');
// A forecast without day figures at all reads the same.
rerender(<PaddleWindCard wind={windOf(ready(forecastOf({ day: null })))} />);
expect(within(screen.getByTestId('paddle-wind-mean')).getByText(NONE)).toBeInTheDocument();
expect(within(screen.getByTestId('paddle-wind-gusts')).getByText(NONE)).toBeInTheDocument();
expect(screen.getByTestId('paddle-wind-card')).not.toHaveTextContent('km/h');
const calm = forecastOf({ day: { speed_max_kmh: 0, gusts_max_kmh: 0, direction_dominant_deg: 0 } });
rerender(<PaddleWindCard wind={windOf(ready(calm))} />);
expect(within(screen.getByTestId('paddle-wind-mean')).getByText('0 km/h')).toBeInTheDocument();
expect(within(screen.getByTestId('paddle-wind-gusts')).getByText('0 km/h')).toBeInTheDocument();
expect(screen.getByTestId('paddle-wind-direction')).toHaveTextContent(/^N$/);
});
it('FE-PADDLE-WIND-014: a tour on two days offers each as a button, marks the one shown and asks for the other', () => {
const { rerender } = renderCard(windOf(ready(forecastOf()), [JULY_8, JULY_9], JULY_9));
const second = screen.getByRole('button', { name: 'Day 2' });
expect(second).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: 'Day 4' })).toHaveAttribute('aria-pressed', 'true');
// The buttons stand in for the date pill a single day gets.
expect(screen.queryByText(dateLabel('2026-07-09'))).not.toBeInTheDocument();
fireEvent.click(second);
expect(chooseDay).toHaveBeenCalledTimes(1);
expect(chooseDay).toHaveBeenCalledWith(11);
// The card follows the day the hook shows.
rerender(<PaddleWindCard wind={windOf(ready(forecastOf()), [JULY_8, JULY_9], JULY_8)} />);
expect(screen.getByRole('button', { name: 'Day 2' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Day 4' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-PADDLE-WIND-015: the hints for paddlers fold out and back in', () => {
renderCard(windOf({ kind: 'offline' }));
const toggle = screen.getByRole('button', { name: 'What wind means for paddling' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('list')).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual(HINTS);
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
});
describe('PaddleWindSection', () => {
it('FE-PADDLE-WIND-016: asks the wind for its tour and start and shows the card for the answer', () => {
hook.useTourWind.mockReturnValue(windOf({ kind: 'offline' }));
render(<PaddleWindSection placeId={7} start={{ lat: 47.05, lng: 8.31 }} />);
expect(hook.useTourWind).toHaveBeenCalledWith(7, { lat: 47.05, lng: 8.31 });
expect(screen.getByRole('region', { name: 'Wind' })).toHaveTextContent(SAYS.offline);
});
});
describe('FigureBadge', () => {
it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => {
const { rerender } = render(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />);
expect(within(screen.getByTestId('figure')).getByText('Height')).not.toHaveClass('text-warning');
expect(screen.getByText('1.2 m').parentElement).toHaveClass('text-warning');
// Without a label the badge holds its value alone, in the neutral tone.
rerender(<FigureBadge testId="figure" value="Private" />);
expect(screen.getByTestId('figure')).toHaveTextContent(/^Private$/);
expect(screen.getByText('Private').parentElement).toHaveClass('text-content');
expect(screen.getByText('Private').parentElement).not.toHaveClass('text-warning');
});
it('FE-PADDLE-FIGURE-002: only a badge with an info opens a tooltip', async () => {
render(
<>
<FigureBadge testId="plain" value="Private" />
<FigureBadge testId="explained" label="Access" value="No" info="Recorded as private on OpenStreetMap" />
</>
);
// Both are hovered: had the plain badge a tooltip of its own, it would still be open afterwards.
fireEvent.mouseEnter(screen.getByTestId('plain'));
expect(await tooltipOf(screen.getByTestId('explained'))).toBe('Recorded as private on OpenStreetMap');
expect(screen.queryAllByRole('tooltip')).toHaveLength(0);
});
});
@@ -0,0 +1,268 @@
import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared';
import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
import { formatDate } from '../../../utils/formatters';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { RailCard, TourNotice } from '../tourParts';
import { FigureBadge } from './FigureBadge';
import { useTourWind, type TourWind } from './useTourWind';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The hours a day tour is out on the water, as the strip shows them. */
const FIRST_HOUR = 6;
const LAST_HOUR = 21;
/**
* An arrow the way the wind blows: meteorological degrees name where it comes
* from, so the arrow turns half round from them.
*/
function FlowArrow({ from, size }: { from: number; size: number }) {
// Drawn about its own centre, so every hour's arrow turns in place at the same height.
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.6}
strokeLinecap="round"
strokeLinejoin="round"
className="flex-none"
style={{ transform: `rotate(${from + 180}deg)` }}
aria-hidden="true"
>
<path d="M12 20V4" />
<path d="M6 10l6-6 6 6" />
</svg>
);
}
function directionName(degrees: number | null, t: Translate): string | null {
return degrees === null ? null : t(`tours.paddle.wind.dir.${compassPointOf(degrees)}`);
}
/**
* The day's hours as columns: the mean wind as a bar, the gusts as the paler
* bar behind it, an arrow the way it blows on top. An hour the source left
* out stays an empty column, never a calm one.
*/
function HourStrip({ hours, t }: { hours: readonly WindHour[]; t: Translate }) {
const shown = hours.filter((hour) => hour.hour >= FIRST_HOUR && hour.hour <= LAST_HOUR);
const top = Math.max(10, ...shown.map((hour) => hour.gusts_kmh ?? hour.speed_kmh ?? 0));
const height = (value: number | null) => `${Math.max(4, ((value ?? 0) / top) * 100)}%`;
return (
<div data-testid="paddle-wind-hours" className="flex items-end gap-[3px]">
{shown.map((hour) => {
const from = hour.direction_deg;
const known = hour.speed_kmh !== null;
const label = known
? t('tours.paddle.wind.hourInfo', {
hour: hour.hour,
speed: hour.speed_kmh,
gusts: hour.gusts_kmh ?? '–',
direction: directionName(from, t) ?? '–',
})
: t('tours.paddle.wind.hourUnknown', { hour: hour.hour });
return (
<Tooltip key={hour.hour} label={label}>
<span className="flex min-w-0 flex-1 flex-col items-center gap-1">
<span className="grid h-3.5 w-3.5 place-items-center text-content-secondary">
{from !== null && <FlowArrow from={from} size={11} />}
</span>
<span
className={`relative flex h-12 w-full items-end justify-center overflow-hidden rounded-[3px] ${known ? 'bg-surface-secondary' : 'border border-dashed border-edge'}`}
>
{hour.gusts_kmh !== null && (
<span
className="absolute inset-x-0 bottom-0 bg-info-soft"
style={{ height: height(hour.gusts_kmh) }}
/>
)}
{known && (
<span
className="relative w-full rounded-t-[2px] bg-info"
style={{ height: height(hour.speed_kmh) }}
/>
)}
</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(9.5)}>
{hour.hour}
</span>
</span>
</Tooltip>
);
})}
</div>
);
}
/** The forecast of the chosen day: its figures, its hours and where and when it was asked. */
function Forecast({ forecast, t, locale }: { forecast: WindForecastResponse; t: Translate; locale: string }) {
const day = forecast.day;
const from = day?.direction_dominant_deg ?? null;
const fetched = new Date(forecast.fetched_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-1">
{from !== null && (
<FigureBadge
testId="paddle-wind-direction"
value={directionName(from, t) ?? ''}
icon={<FlowArrow from={from} size={11} />}
info={t('tours.paddle.wind.fromInfo', { direction: directionName(from, t) ?? '' })}
/>
)}
<FigureBadge
testId="paddle-wind-mean"
label={t('tours.paddle.wind.mean')}
value={day?.speed_max_kmh == null ? '–' : `${day.speed_max_kmh} km/h`}
info={t('tours.paddle.wind.meanInfo')}
/>
<FigureBadge
testId="paddle-wind-gusts"
label={t('tours.paddle.wind.gusts')}
value={day?.gusts_max_kmh == null ? '–' : `${day.gusts_max_kmh} km/h`}
info={t('tours.paddle.wind.gustsInfo')}
/>
</div>
<HourStrip hours={forecast.hours} t={t} />
<div className="-mt-1 flex items-center gap-3 text-content-muted" style={fs(10.5, 'body')}>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-[2px] bg-info" />
{t('tours.paddle.wind.legendMean')}
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-[2px] bg-info-soft ring-1 ring-inset ring-edge-faint" />
{t('tours.paddle.wind.legendGusts')}
</span>
</div>
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.wind.source', { source: forecast.source, time: fetched })}
</p>
</div>
);
}
/** Why there is no forecast to show, each case in its own words, with a way to ask again after a failure. */
function Unavailable({ wind, t, locale }: { wind: TourWind; t: Translate; locale: string }) {
const { state } = wind;
if (state.kind === 'loading')
return <div data-testid="paddle-wind-loading" className="h-24 animate-pulse rounded-[10px] bg-surface-secondary" />;
if (state.kind === 'error')
return (
<TourNotice tone="warning" icon={<RefreshCw size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2">
{t('tours.paddle.wind.error')}
<button type="button" onClick={wind.retry} className="font-semibold underline-offset-2 hover:underline">
{t('tours.paddle.wind.retry')}
</button>
</span>
</TourNotice>
);
if (state.kind === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.paddle.wind.offline')}</TourNotice>;
if (state.kind === 'ready' && state.forecast.status === 'beyond_horizon') {
const from = new Date(Date.parse(state.forecast.date) - (state.forecast.horizon_days - 1) * 86_400_000);
return (
<TourNotice icon={<Wind size={13} strokeWidth={2} />}>
{t('tours.paddle.wind.beyond', { date: formatDate(from.toISOString().slice(0, 10), locale) })}
</TourNotice>
);
}
// A day without a forecast to ask for: past, not on a day, a day without a date, a tour without a start.
const notice = state.kind === 'ready' ? t('tours.paddle.wind.past') : t(`tours.paddle.wind.${state.kind}`);
return <TourNotice icon={<Wind size={13} strokeWidth={2} />}>{notice}</TourNotice>;
}
/** What wind means for a paddler, in general terms; it rates no passage and clears nothing. */
function WindHints({ t }: { t: Translate }) {
const [open, setOpen] = useState(false);
return (
<div className="border-t border-edge-faint pt-2">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center gap-1.5 text-start font-semibold text-content-secondary hover:text-content"
style={fs(11.5, 'body')}
>
<span className="min-w-0 flex-1 truncate">{t('tours.paddle.wind.hintsTitle')}</span>
<ChevronDown
size={13}
strokeWidth={2.2}
className={`flex-none transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<ul className="m-0 mt-2 flex list-disc flex-col gap-1.5 ps-4 text-content-muted" style={fs(11.5, 'body')}>
{(['headwind', 'drift', 'crossing', 'gusts', 'local'] as const).map((hint) => (
<li key={hint}>{t(`tours.paddle.wind.hint.${hint}`)}</li>
))}
</ul>
)}
</div>
);
}
/**
* PAD-08: the wind on the day a paddle tour is planned for, at its start. The
* mean, the gusts and the direction are figures of their own, with the source
* and when it was asked; no value is turned into a verdict, and missing data
* is never shown as calm.
*/
export function PaddleWindCard({ wind }: { wind: TourWind }) {
const { t, locale } = useTranslation();
const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null;
return (
<RailCard
icon={<Wind size={14} strokeWidth={2} />}
title={t('tours.paddle.wind.title')}
aside={
wind.tourDays.length > 1 ? (
<span className="flex flex-none items-center gap-1">
{wind.tourDays.map(({ day, index }) => (
<button
key={day.id}
type="button"
aria-pressed={wind.day?.day.id === day.id}
onClick={() => wind.chooseDay(day.id)}
className={`rounded-full px-2 font-semibold leading-[20px] transition-colors ${wind.day?.day.id === day.id ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-secondary ring-1 ring-inset ring-edge-faint hover:text-content'}`}
style={fs(10.5)}
>
{t('dayplan.dayN', { n: index + 1 })}
</button>
))}
</span>
) : wind.day ? (
<SoftPill>{formatDate(wind.day.day.date, locale) || t('dayplan.dayN', { n: wind.day.index + 1 })}</SoftPill>
) : null
}
>
<div data-testid="paddle-wind-card" className="flex flex-col gap-3">
{ready ? (
<Forecast forecast={ready} t={t} locale={locale} />
) : (
<Unavailable wind={wind} t={t} locale={locale} />
)}
<WindHints t={t} />
</div>
</RailCard>
);
}
/** The wind card for one tour: its days read from the trip, its start as the place the forecast is for. */
export function PaddleWindSection({
placeId,
start,
}: {
placeId: number | null;
start: { lat: number; lng: number } | null;
}) {
const wind = useTourWind(placeId, start);
return <PaddleWindCard wind={wind} />;
}
@@ -0,0 +1,487 @@
// FE-COMP-PADDLE-TRACKPLAN-001 to FE-COMP-PADDLE-TRACKPLAN-016: the cards of a recorded paddle track, its time
// estimate reckoned with the portages and locks laid onto it, and the card that marks them by a click on the line.
import {
DEFAULT_PORTAGE_SPEED_KMH,
estimatePaddleTime,
paddleLayoutOfTrack,
recommendedPaddleSpeedKmh,
type TourListItem,
type TrackMarks,
} from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useShallow } from 'zustand/react/shallow';
import { buildSettings } from '../../../../tests/helpers/factories';
import { act, fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import {
loadTrackMarks,
pickOnTrack,
refuseTrackPick,
startTrackPick,
trackMarkEdits,
useTrackMarkToolStore,
type TrackSpeeds,
} from '../../../store/trackMarkToolStore';
import { NO_TRACK_MARKS } from '../../../utils/paddleTrackMarks';
import { TrackPaddlePlanCards } from './TrackPaddlePlanCard';
import type { TrackPaddlePlan } from './useTrackPaddlePlan';
const EMPTY = 'Nothing marked yet. The recorded track stays as it is.';
/** What the card says a click on the line does next, or why the last one placed nothing. */
const HINT = {
lock: 'Click the line where the lock is.',
takeOut: 'Click the line where you get out.',
reEntry: 'Now click where you put back in.',
overlap: 'A portage is already marked there. Mark the new one beside it.',
offLine: 'That was too far from the line. Click right on the track.',
};
/** A time that is not known reads as an en dash; a planned total that falls short carries a real minus sign. */
const DASH = '–';
const MINUS = '−';
/** The recorded track is 6.5 km long. */
const TOTAL_M = 6500;
const NO_SPEEDS: TrackSpeeds = { paddle: null, portage: null };
/** A portage from 2 to 2.5 km at the starting time out and in, and a lock at 4 km with both its times. */
const MARKS: TrackMarks = {
portages: [{ from_m: 2000, to_m: 2500, extra_minutes: null }],
locks: [{ at_m: 4000, wait_minutes: 5, transit_minutes: 10 }],
};
/** Two of each in order along the track: the second portage has its own time, the second lock none yet. */
const TWO: TrackMarks = {
portages: [
{ from_m: 1000, to_m: 1300, extra_minutes: null },
{ from_m: 3000, to_m: 3400, extra_minutes: 25 },
],
locks: [
{ at_m: 2000, wait_minutes: 5, transit_minutes: 10 },
{ at_m: 5000, wait_minutes: null, transit_minutes: null },
],
};
const TOUR: TourListItem = {
place_id: 21,
name: 'Recorded river run',
tour_type: 'kayak',
distance: 6.5,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: null,
planned: false,
caution: false,
boat_type: 'kayak',
break_additional_minutes: 20,
planned_duration_minutes: 120,
track_marks: MARKS,
};
/**
* A plan as useTrackPaddlePlan hands it to the cards: an open paddle track the
* viewer may edit, nothing picked, its layout and estimate reckoned from the
* marks and speeds the way the hook reckons them, and every action a spy.
*/
function planOf({
marks = NO_TRACK_MARKS,
speeds = NO_SPEEDS,
recommendedPaddleSpeed = recommendedPaddleSpeedKmh(TOUR.boat_type),
...rest
}: Partial<TrackPaddlePlan> = {}): TrackPaddlePlan {
const layout = paddleLayoutOfTrack(TOTAL_M, marks);
return {
active: true,
canEdit: true,
saving: false,
dirty: false,
pick: null,
pendingFromM: null,
refused: null,
totalM: TOTAL_M,
marks,
speeds,
layout,
estimate: estimatePaddleTime(layout, {
paddleSpeedKmh: speeds.paddle ?? recommendedPaddleSpeed,
portageSpeedKmh: speeds.portage,
breakMinutes: null,
}),
recommendedPaddleSpeed,
defaultPortageSpeed: DEFAULT_PORTAGE_SPEED_KMH,
startPick: vi.fn(),
edits: { removePortage: vi.fn(), setPortageExtra: vi.fn(), setLock: vi.fn(), setSpeeds: vi.fn() },
save: vi.fn(() => Promise.resolve()),
discard: vi.fn(),
...rest,
};
}
/** The two cards for a plan, on the tour the plan belongs to. */
function cards(plan: TrackPaddlePlan, tour: TourListItem = TOUR) {
return <TrackPaddlePlanCards plan={plan} tour={tour} />;
}
/** The cards on the shared store the map is clicked into, wired to it the way useTrackPaddlePlan wires them. */
function StoreCards() {
const state = useTrackMarkToolStore(
useShallow((store) => ({
marks: store.marks,
speeds: store.speeds,
dirty: store.dirty,
pick: store.pick,
pendingFromM: store.pendingFromM,
refused: store.refused,
}))
);
const plan = planOf({
...state,
startPick: (kind) => startTrackPick(state.pick === kind ? null : kind),
edits: trackMarkEdits,
});
return cards(plan);
}
/** Opens a portage or lock row to its fields. */
function openRow(row: HTMLElement): void {
fireEvent.click(within(row).getByRole('button', { expanded: false }));
}
/** Types into a field and leaves it: the fields take their value on leaving. */
function enter(input: HTMLElement, value: string): void {
fireEvent.change(input, { target: { value } });
fireEvent.blur(input);
}
describe('TrackPaddlePlanCards', () => {
beforeEach(() => resetAllStores());
it('FE-COMP-PADDLE-TRACKPLAN-001: shows nothing while no recorded paddle track is open', () => {
const { container } = render(cards(planOf({ active: false, marks: MARKS, dirty: true })));
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-PADDLE-TRACKPLAN-002: reckons the time with the portages and locks laid on, the breaks, and the planned total beside it', () => {
render(cards(planOf({ marks: MARKS })));
expect(screen.getByRole('region', { name: 'Time estimate' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Portages and locks' })).toBeInTheDocument();
// 6 km of water at the kayak's 4 km/h, 500 m carried at 2 km/h, 10 min out and in, a 15 min lock, 20 min of breaks.
expect(screen.getByTestId('paddle-time-estimate')).toHaveTextContent('2 h 30 min');
const water = screen.getByTestId('paddle-time-water');
expect(water).toHaveTextContent('6 km');
expect(water).toHaveTextContent('1 h 30 min');
expect(screen.getByTestId('paddle-time-water-speed')).toHaveAttribute('placeholder', '4');
const carry = screen.getByTestId('paddle-time-carry');
expect(carry).toHaveTextContent('500 m');
expect(carry).toHaveTextContent('15 min');
expect(screen.getByTestId('paddle-time-carry-speed')).toHaveAttribute('placeholder', '2');
expect(screen.getByTestId('paddle-time-out-in')).toHaveTextContent('1×');
expect(screen.getByTestId('paddle-time-out-in')).toHaveTextContent('10 min');
expect(screen.getByTestId('paddle-time-locks')).toHaveTextContent('1×');
expect(screen.getByTestId('paddle-time-locks')).toHaveTextContent('15 min');
expect(screen.getByTestId('paddle-time-breaks')).toHaveTextContent('20 min');
const planned = screen.getByTestId('paddle-time-planned');
expect(planned).toHaveTextContent('Your planned total');
expect(within(planned).getByText('2 h')).toBeInTheDocument();
expect(screen.getByTestId('paddle-time-deviation')).toHaveTextContent(`${MINUS}30 min`);
expect(screen.queryByText('Incomplete')).not.toBeInTheDocument();
expect(screen.getAllByTestId('paddle-route-strip-lock')).toHaveLength(1);
});
it('FE-COMP-PADDLE-TRACKPLAN-003: without a paddling speed or lock times the estimate is incomplete and names what is missing', () => {
const marks: TrackMarks = { portages: [], locks: [{ at_m: 3000, wait_minutes: null, transit_minutes: null }] };
const tour = { ...TOUR, break_additional_minutes: null, planned_duration_minutes: null };
render(cards(planOf({ marks, recommendedPaddleSpeed: null }), tour));
const missing = 'Without a paddling speed the time on the water stays open. One lock has no times yet.';
expect(screen.getByText(missing)).toBeInTheDocument();
expect(screen.getByText('Incomplete')).toBeInTheDocument();
expect(screen.getByTestId('paddle-time-estimate')).toHaveTextContent('0 min');
const water = screen.getByTestId('paddle-time-water');
expect(water).toHaveTextContent('6.5 km');
expect(within(water).getByText(DASH)).toBeInTheDocument();
expect(screen.getByTestId('paddle-time-water-speed')).toHaveAttribute('placeholder', DASH);
const locks = screen.getByTestId('paddle-time-locks');
expect(locks).toHaveTextContent('1×');
expect(within(locks).getByText(DASH)).toHaveClass('text-warning');
expect(within(screen.getByTestId('paddle-time-breaks')).getByText(DASH)).toBeInTheDocument();
expect(screen.queryByTestId('paddle-time-planned')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-time-carry')).not.toBeInTheDocument();
});
it('FE-COMP-PADDLE-TRACKPLAN-004: the paddling and carrying speeds are typed in place, with either decimal separator, empty for the starting value', () => {
const plan = planOf({ marks: MARKS, speeds: { paddle: 4.5, portage: null } });
render(cards(plan));
const paddle = screen.getByTestId('paddle-time-water-speed');
const carry = screen.getByTestId('paddle-time-carry-speed');
expect(paddle).toHaveValue('4.5');
expect(screen.getByTestId('paddle-time-water')).toHaveTextContent('1 h 20 min');
expect(carry).toHaveValue('');
expect(carry).toHaveAttribute('placeholder', '2');
enter(paddle, '5,5');
expect(plan.edits.setSpeeds).toHaveBeenCalledWith({ paddle: 5.5 });
enter(carry, '3');
expect(plan.edits.setSpeeds).toHaveBeenLastCalledWith({ portage: 3 });
enter(paddle, '');
expect(plan.edits.setSpeeds).toHaveBeenLastCalledWith({ paddle: null });
});
it('FE-COMP-PADDLE-TRACKPLAN-005: with nothing marked it says the track stays as recorded, and each tool asks for its own pick', () => {
const plan = planOf();
render(cards(plan));
expect(screen.getByText(EMPTY)).toBeInTheDocument();
expect(screen.queryByTestId('tour-planner-portage-leg')).not.toBeInTheDocument();
expect(screen.queryByTestId('tour-planner-lock')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-time-carry')).not.toBeInTheDocument();
for (const hint of Object.values(HINT)) expect(screen.queryByText(hint)).not.toBeInTheDocument();
const portage = screen.getByTestId('track-marks-pick-portage');
const lock = screen.getByTestId('track-marks-pick-lock');
expect(portage).toHaveTextContent('Portage');
expect(lock).toHaveTextContent('Lock');
expect(portage).toHaveAttribute('aria-pressed', 'false');
expect(lock).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(portage);
expect(plan.startPick).toHaveBeenCalledWith('portage');
fireEvent.click(lock);
expect(plan.startPick).toHaveBeenLastCalledWith('lock');
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
});
it('FE-COMP-PADDLE-TRACKPLAN-006: the tool that is on shows pressed and says what the next click on the line does', () => {
const { rerender } = render(cards(planOf({ pick: 'lock' })));
expect(screen.getByTestId('track-marks-pick-lock')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('track-marks-pick-portage')).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText(HINT.lock).parentElement).toHaveClass('bg-surface-card');
expect(screen.getByText(HINT.lock).parentElement).not.toHaveClass('bg-warning-soft');
rerender(cards(planOf({ pick: 'portage' })));
expect(screen.getByTestId('track-marks-pick-portage')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText(HINT.takeOut)).toBeInTheDocument();
expect(screen.queryByText(HINT.lock)).not.toBeInTheDocument();
rerender(cards(planOf({ pick: 'portage', pendingFromM: 2000 })));
expect(screen.getByText(HINT.reEntry)).toBeInTheDocument();
expect(screen.queryByText(HINT.takeOut)).not.toBeInTheDocument();
});
it('FE-COMP-PADDLE-TRACKPLAN-007: a click that placed nothing says why in the warning tone, instead of what the tool does', () => {
const { rerender } = render(cards(planOf({ pick: 'portage', refused: 'overlap' })));
expect(screen.getByText(HINT.overlap).parentElement).toHaveClass('bg-warning-soft');
expect(screen.queryByText(HINT.takeOut)).not.toBeInTheDocument();
rerender(cards(planOf({ pick: 'portage', refused: 'off_line' })));
expect(screen.getByText(HINT.offLine).parentElement).toHaveClass('bg-warning-soft');
expect(screen.queryByText(HINT.overlap)).not.toBeInTheDocument();
});
it('FE-COMP-PADDLE-TRACKPLAN-008: lists every portage and lock where it lies on the track, with its length and its times', () => {
render(cards(planOf({ marks: TWO })));
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
const [near, far] = screen.getAllByTestId('tour-planner-portage-leg');
expect(near).toHaveTextContent('at 1 km');
expect(near).toHaveTextContent('300 m');
expect(near).toHaveTextContent('+10 min');
expect(far).toHaveTextContent('at 3 km');
expect(far).toHaveTextContent('400 m');
expect(far).toHaveTextContent('+25 min');
const [timed, untimed] = screen.getAllByTestId('tour-planner-lock');
expect(timed).toHaveTextContent('at 2 km');
expect(timed).toHaveTextContent('15 min');
expect(untimed).toHaveTextContent('at 5 km');
expect(within(untimed).getByText('Time missing')).toHaveClass('text-warning');
openRow(near);
expect(within(near).getByText('Starting value')).toBeInTheDocument();
expect(within(near).getByTestId('tour-planner-portage-extra')).toHaveAttribute('placeholder', '10');
});
it('FE-COMP-PADDLE-TRACKPLAN-009: a lock a portage carries round is dimmed, reads carried round, and its times do not count', () => {
const marks: TrackMarks = {
portages: [{ from_m: 2000, to_m: 2500, extra_minutes: null }],
locks: [{ at_m: 2200, wait_minutes: null, transit_minutes: null }],
};
render(cards(planOf({ marks })));
const lock = screen.getByTestId('tour-planner-lock');
expect(lock).toHaveTextContent('Carried round, the portage counts');
expect(lock).not.toHaveTextContent('Time missing');
expect(within(lock).getByRole('button', { expanded: false })).toHaveClass('opacity-60');
expect(screen.queryByTestId('paddle-time-locks')).not.toBeInTheDocument();
expect(screen.queryByText('Incomplete')).not.toBeInTheDocument();
});
it('FE-COMP-PADDLE-TRACKPLAN-010: a change or a removal goes to the mark of its own row, by its place in the list', () => {
const plan = planOf({ marks: TWO });
render(cards(plan));
const portage = screen.getAllByTestId('tour-planner-portage-leg')[1];
openRow(portage);
const extra = within(portage).getByTestId('tour-planner-portage-extra');
enter(extra, '15');
expect(plan.edits.setPortageExtra).toHaveBeenCalledWith(1, 15);
enter(extra, '');
expect(plan.edits.setPortageExtra).toHaveBeenLastCalledWith(1, null);
fireEvent.click(within(portage).getByRole('button', { name: 'Remove the portage' }));
expect(plan.edits.removePortage).toHaveBeenCalledWith(1);
const lock = screen.getAllByTestId('tour-planner-lock')[1];
openRow(lock);
enter(within(lock).getByTestId('tour-planner-lock-wait'), '8');
expect(plan.edits.setLock).toHaveBeenCalledWith(1, { at_m: 5000, wait_minutes: 8, transit_minutes: null });
fireEvent.click(within(lock).getByRole('button', { name: 'Remove the lock' }));
expect(plan.edits.setLock).toHaveBeenLastCalledWith(1, null);
});
it('FE-COMP-PADDLE-TRACKPLAN-011: says where a mark lies in the distance unit the user reads, metric without one', () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
render(cards(planOf({ marks: MARKS })));
expect(screen.getByTestId('tour-planner-portage-leg')).toHaveTextContent('at 1.2 mi');
expect(screen.getByTestId('tour-planner-portage-leg')).toHaveTextContent('0.3 mi');
expect(screen.getByTestId('tour-planner-lock')).toHaveTextContent('at 2.5 mi');
act(() => seedStore(useSettingsStore, { settings: buildSettings() }));
expect(screen.getByTestId('tour-planner-portage-leg')).toHaveTextContent('at 2 km');
expect(screen.getByTestId('tour-planner-lock')).toHaveTextContent('at 4 km');
});
it('FE-COMP-PADDLE-TRACKPLAN-012: offers save and discard only once something changed, and hands each on', () => {
const { rerender } = render(cards(planOf({ marks: MARKS })));
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Discard' })).not.toBeInTheDocument();
const plan = planOf({ marks: MARKS, dirty: true });
rerender(cards(plan));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(plan.save).toHaveBeenCalledTimes(1);
expect(plan.discard).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(plan.discard).toHaveBeenCalledTimes(1);
});
it('FE-COMP-PADDLE-TRACKPLAN-013: while it saves, the tools, the fields and both buttons hold still and nothing can be removed', () => {
const plan = planOf({ marks: MARKS, dirty: true, saving: true });
render(cards(plan));
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled();
expect(screen.getByTestId('track-marks-pick-portage')).toBeDisabled();
const pickLock = screen.getByTestId('track-marks-pick-lock');
expect(pickLock).toBeDisabled();
fireEvent.click(pickLock);
expect(plan.startPick).not.toHaveBeenCalled();
expect(screen.getByTestId('paddle-time-water-speed')).toBeDisabled();
expect(screen.getByTestId('paddle-time-carry-speed')).toBeDisabled();
const portage = screen.getByTestId('tour-planner-portage-leg');
openRow(portage);
expect(within(portage).getByTestId('tour-planner-portage-extra')).toBeDisabled();
expect(within(portage).queryByRole('button', { name: 'Remove the portage' })).not.toBeInTheDocument();
const lock = screen.getByTestId('tour-planner-lock');
openRow(lock);
expect(within(lock).queryByRole('button', { name: 'Remove the lock' })).not.toBeInTheDocument();
});
it('FE-COMP-PADDLE-TRACKPLAN-014: a viewer who may not edit reads the marks and the estimate, with no tools, no save and nothing to change', () => {
render(cards(planOf({ marks: MARKS, dirty: true, canEdit: false })));
expect(screen.queryByTestId('track-marks-pick-portage')).not.toBeInTheDocument();
expect(screen.queryByTestId('track-marks-pick-lock')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Discard' })).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-time-estimate')).toHaveTextContent('2 h 30 min');
expect(screen.getByTestId('paddle-time-water-speed')).toBeDisabled();
expect(screen.getByTestId('paddle-time-carry-speed')).toBeDisabled();
const lock = screen.getByTestId('tour-planner-lock');
openRow(lock);
expect(within(lock).getByTestId('tour-planner-lock-wait')).toBeDisabled();
expect(within(lock).getByTestId('tour-planner-lock-transit')).toBeDisabled();
expect(within(lock).queryByRole('button', { name: 'Remove the lock' })).not.toBeInTheDocument();
const portage = screen.getByTestId('tour-planner-portage-leg');
openRow(portage);
expect(within(portage).queryByRole('button', { name: 'Remove the portage' })).not.toBeInTheDocument();
});
it('FE-COMP-PADDLE-TRACKPLAN-015: on the shared store, two clicks lay a portage, a refused click says why, and Remove takes it off', () => {
loadTrackMarks(TOUR.place_id, null, NO_SPEEDS);
render(<StoreCards />);
expect(screen.getByText(EMPTY)).toBeInTheDocument();
// A second press on the tool that is on turns it off again.
const pickLock = screen.getByTestId('track-marks-pick-lock');
fireEvent.click(pickLock);
expect(pickLock).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(pickLock);
expect(pickLock).toHaveAttribute('aria-pressed', 'false');
expect(screen.queryByText(HINT.lock)).not.toBeInTheDocument();
const pickPortage = screen.getByTestId('track-marks-pick-portage');
fireEvent.click(pickPortage);
expect(screen.getByText(HINT.takeOut)).toBeInTheDocument();
act(() => pickOnTrack(1000));
expect(screen.getByText(HINT.reEntry)).toBeInTheDocument();
act(() => pickOnTrack(1500));
const portage = screen.getByTestId('tour-planner-portage-leg');
expect(portage).toHaveTextContent('at 1 km');
expect(portage).toHaveTextContent('500 m');
expect(pickPortage).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByTestId('paddle-time-carry')).toHaveTextContent('500 m');
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
fireEvent.click(pickPortage);
act(() => pickOnTrack(1200));
act(() => pickOnTrack(1800));
expect(screen.getByText(HINT.overlap)).toBeInTheDocument();
expect(screen.getAllByTestId('tour-planner-portage-leg')).toHaveLength(1);
act(() => refuseTrackPick());
expect(screen.getByText(HINT.offLine)).toBeInTheDocument();
openRow(portage);
fireEvent.click(within(portage).getByRole('button', { name: 'Remove the portage' }));
expect(screen.queryByTestId('tour-planner-portage-leg')).not.toBeInTheDocument();
expect(useTrackMarkToolStore.getState().marks.portages).toEqual([]);
expect(screen.getByText(EMPTY)).toBeInTheDocument();
});
it('FE-COMP-PADDLE-TRACKPLAN-016: on the shared store, a lock laid on the line misses its times until both are entered', () => {
loadTrackMarks(TOUR.place_id, null, NO_SPEEDS);
render(<StoreCards />);
fireEvent.click(screen.getByTestId('track-marks-pick-lock'));
expect(screen.getByText(HINT.lock)).toBeInTheDocument();
act(() => pickOnTrack(3000.4));
expect(screen.getByTestId('track-marks-pick-lock')).toHaveAttribute('aria-pressed', 'false');
expect(screen.queryByText(HINT.lock)).not.toBeInTheDocument();
const lock = screen.getByTestId('tour-planner-lock');
expect(lock).toHaveTextContent('at 3 km');
expect(lock).toHaveTextContent('Time missing');
expect(screen.getByText('One lock has no times yet.')).toBeInTheDocument();
expect(screen.getByText('Incomplete')).toBeInTheDocument();
openRow(lock);
enter(within(lock).getByTestId('tour-planner-lock-wait'), '5');
enter(within(lock).getByTestId('tour-planner-lock-transit'), '10');
expect(lock).toHaveTextContent('15 min');
expect(lock).not.toHaveTextContent('Time missing');
expect(screen.queryByText('One lock has no times yet.')).not.toBeInTheDocument();
expect(screen.queryByText('Incomplete')).not.toBeInTheDocument();
const { marks } = useTrackMarkToolStore.getState();
expect(marks.locks).toEqual([{ at_m: 3000, wait_minutes: 5, transit_minutes: 10 }]);
fireEvent.click(within(lock).getByRole('button', { name: 'Remove the lock' }));
expect(screen.queryByTestId('tour-planner-lock')).not.toBeInTheDocument();
expect(useTrackMarkToolStore.getState()).toMatchObject({ dirty: true, marks: { locks: [] } });
});
});
@@ -0,0 +1,184 @@
import type { TourListItem } from '@trek/shared';
import { MousePointerClick, Plus, RotateCcw, Save } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { TrackPick } from '../../../store/trackMarkToolStore';
import { formatDistance } from '../../../utils/units';
import { DialogButton, fs } from '../../shared/DialogShell';
import { RailCard, TourNotice } from '../tourParts';
import { PaddleGlyph } from './PaddleGlyph';
import { PaddleEstimateBody, PaddleTimeCardShell } from './PaddleTimeCard';
import { LockRow, PortageLegRow } from './PaddleWaypointParts';
import type { TrackPaddlePlan } from './useTrackPaddlePlan';
/** One of the two tools: pressed while a click on the line places its mark. */
function PickButton({
active,
disabled,
onPress,
icon,
children,
testId,
}: {
active: boolean;
disabled: boolean;
onPress: () => void;
icon: ReactNode;
children: ReactNode;
testId: string;
}) {
return (
<button
type="button"
data-testid={testId}
aria-pressed={active}
disabled={disabled}
onClick={onPress}
className={`flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 font-semibold transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint enabled:hover:text-content'}`}
style={fs(11.5, 'body')}
>
<Plus size={12} strokeWidth={2.6} className="flex-none" />
<span className="flex-none">{icon}</span>
<span className="min-w-0 truncate">{children}</span>
</button>
);
}
/** What a click on the line does now, or why the last one placed nothing. */
function pickHint(plan: TrackPaddlePlan, t: (key: string) => string): string | null {
if (plan.refused === 'overlap') return t('tours.paddle.marks.overlap');
if (plan.refused === 'off_line') return t('tours.paddle.marks.offLine');
if (plan.pick === 'lock') return t('tours.paddle.marks.pickLock');
if (plan.pick === 'portage')
return t(plan.pendingFromM === null ? 'tours.paddle.marks.pickTakeOut' : 'tours.paddle.marks.pickReEntry');
return null;
}
/**
* The portages and locks laid onto a recorded paddle track: two tools to mark
* them by clicking the line, the list of what is marked with its times, and
* save once something changed. The recorded track is never redrawn.
*/
function TrackMarksCard({ plan }: { plan: TrackPaddlePlan }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const at = (m: number) => t('tours.paddle.marks.at', { distance: formatDistance(m / 1000, unit) });
const locked = !plan.canEdit || plan.saving;
const hint = pickHint(plan, t);
const pick = (kind: TrackPick) => () => plan.startPick(kind);
const empty = plan.marks.portages.length === 0 && plan.marks.locks.length === 0;
return (
<RailCard icon={<PaddleGlyph kind="portage" size={14} />} title={t('tours.paddle.marks.title')}>
<div data-testid="track-marks-card" className="flex flex-col gap-2.5">
{plan.canEdit && (
<div className="flex gap-1.5">
<PickButton
testId="track-marks-pick-portage"
active={plan.pick === 'portage'}
disabled={locked}
onPress={pick('portage')}
icon={<PaddleGlyph kind="portage" size={13} strokeWidth={2.2} />}
>
{t('tours.paddle.marks.markPortage')}
</PickButton>
<PickButton
testId="track-marks-pick-lock"
active={plan.pick === 'lock'}
disabled={locked}
onPress={pick('lock')}
icon={<PaddleGlyph kind="lock" size={13} strokeWidth={2.2} />}
>
{t('tours.paddle.marks.markLock')}
</PickButton>
</div>
)}
{hint && (
<TourNotice
tone={plan.refused ? 'warning' : 'neutral'}
icon={<MousePointerClick size={13} strokeWidth={2} />}
>
{hint}
</TourNotice>
)}
{empty ? (
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('tours.paddle.marks.empty')}
</p>
) : (
<ul className="m-0 -ms-[34px] flex list-none flex-col p-0">
{plan.marks.portages.map((portage, index) => (
<PortageLegRow
key={`p-${portage.from_m}`}
lengthM={portage.to_m - portage.from_m}
extraMinutes={portage.extra_minutes}
disabled={locked}
position={at(portage.from_m)}
onExtraChange={(minutes) => plan.edits.setPortageExtra(index, minutes)}
onRemove={() => plan.edits.removePortage(index)}
/>
))}
{plan.marks.locks.map((lock, index) => (
<LockRow
key={`l-${lock.at_m}-${index}`}
lock={lock}
bypassed={!!plan.layout.locks.find((stop) => stop.atM === lock.at_m)?.bypassed}
disabled={locked}
position={at(lock.at_m)}
onChange={(next) => plan.edits.setLock(index, next)}
/>
))}
</ul>
)}
{plan.dirty && plan.canEdit && (
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={plan.discard} disabled={plan.saving}>
{t('common.discard')}
</DialogButton>
<DialogButton
variant="primary"
icon={<Save size={14} strokeWidth={2} />}
onClick={() => void plan.save()}
disabled={plan.saving}
>
{t('common.save')}
</DialogButton>
</div>
)}
</div>
</RailCard>
);
}
/**
* PAD-03 and PAD-07 on a recorded paddle track: its time estimate, reckoned
* with the portages and locks laid onto it, and the card to lay them.
*/
export function TrackPaddlePlanCards({ plan, tour }: { plan: TrackPaddlePlan; tour: TourListItem }) {
if (!plan.active) return null;
const locked = !plan.canEdit || plan.saving;
return (
<>
<PaddleTimeCardShell>
<PaddleEstimateBody
estimate={plan.estimate}
layout={plan.layout}
breakMinutes={tour.break_additional_minutes ?? null}
planned={tour.planned_duration_minutes ?? null}
locked={locked}
portageSpeed={{
value: plan.speeds.portage,
startValue: plan.defaultPortageSpeed,
onChange: (portage) => plan.edits.setSpeeds({ portage }),
}}
paddleSpeed={{
value: plan.speeds.paddle,
startValue: plan.recommendedPaddleSpeed,
onChange: (paddle) => plan.edits.setSpeeds({ paddle }),
}}
/>
</PaddleTimeCardShell>
<TrackMarksCard plan={plan} />
</>
);
}
@@ -0,0 +1,277 @@
// PCM-001 to PCM-019: the check bench of a paddle tour in one pill, how the dates of the checklist read, where the
// camera goes for an entry and the order the checklist lists its rows in (PAD-06, PAD-09).
import { Ban, CircleDashed, ListChecks } from 'lucide-react';
import { describe, expect, it } from 'vitest';
import type { PaddleCheck, PaddleCheckData } from '@trek/shared';
import {
earlierEntry,
obstacleCheck,
obstacleEntry,
ownPortage,
paddleBench,
} from '../../../../tests/helpers/paddleCheck';
import {
checkDataWord,
checklistRows,
focusPointsOf,
formatCheckDate,
nextPendingEntry,
paddleCheckFact,
} from './paddleCheckModel';
/** The key as it is, with its values appended: a test reads which string and which count. */
const t = (key: string, params?: Record<string, string | number>) => (params ? key + JSON.stringify(params) : key);
const fact = (bench: Partial<PaddleCheck>) => paddleCheckFact(paddleBench(bench), t);
const lines = (bench: Partial<PaddleCheck>) => fact(bench).label.split('\n');
const NOT_CLEARANCE = 'tours.paddle.check.notClearance';
describe('paddleCheckFact', () => {
it('PCM-001: missing data says so and never shows a count, whatever the bench counts', () => {
for (const data of ['unavailable', 'none'] as const) {
const shown = fact({ data, open: 2, review: 1, decided: 3, bans: 1 });
expect(shown.value).toBe(`tours.paddle.check.data.${data}`);
expect(shown.value).not.toContain('count');
expect(shown.icon).toBe(CircleDashed);
}
expect(lines({ data: 'none', entries: 0, open: 0 })).toEqual([
'tours.paddle.check.dataLabel: tours.paddle.check.data.none',
NOT_CLEARANCE,
]);
});
it('PCM-002: open entries and the ones to check again are counted together in the pill, apart in the label', () => {
const shown = fact({ entries: 4, open: 2, review: 1, decided: 1 });
expect(shown.value).toBe('tours.paddle.check.open{"count":3}');
expect(shown.icon).toBe(ListChecks);
expect(shown.label.split('\n')).toEqual([
'tours.paddle.check.open{"count":2}',
'tours.paddle.check.review{"count":1}',
'tours.paddle.check.decided{"count":1}',
NOT_CLEARANCE,
]);
expect(fact({ entries: 2, open: 0, review: 2 }).value).toBe('tours.paddle.check.open{"count":2}');
});
it('PCM-003: open entries come before a partial coverage, and with nothing open the coverage word shows', () => {
expect(fact({ data: 'partial', open: 1 }).value).toBe('tours.paddle.check.open{"count":1}');
expect(fact({ data: 'partial', open: 0, decided: 1 }).value).toBe('tours.paddle.check.data.partial');
expect(fact({ data: 'limited', open: 0, decided: 1 }).value).toBe('tours.paddle.check.data.limited');
expect(lines({ data: 'limited', open: 0, decided: 1 })[0]).toBe(
'tours.paddle.check.dataLabel: tours.paddle.check.data.limited'
);
});
it('PCM-004: a route without known obstacles says none are known, never that it is clear', () => {
const shown = fact({ entries: 0, open: 0 });
expect(shown.value).toBe('tours.paddle.check.noneKnown');
expect(shown.icon).toBe(ListChecks);
expect(shown.label).toBe(NOT_CLEARANCE);
// Data that covers only part of the route says that first.
expect(fact({ data: 'partial', entries: 0, open: 0 }).value).toBe('tours.paddle.check.data.partial');
});
it('PCM-005: with every entry decided it counts the decisions', () => {
const shown = fact({ entries: 3, open: 0, decided: 3 });
expect(shown.value).toBe('tours.paddle.check.decided{"count":3}');
expect(shown.icon).toBe(ListChecks);
});
it('PCM-006: a ban on the route shows the ban icon, and the label says a decision does not lift it', () => {
const shown = fact({ entries: 2, open: 0, decided: 2, bans: 1 });
expect(shown.icon).toBe(Ban);
expect(shown.value).toBe('tours.paddle.check.decided{"count":2}');
expect(shown.label.split('\n')).toEqual([
'tours.paddle.check.decided{"count":2}',
'tours.paddle.check.bans{"count":1}',
'tours.paddle.check.bansInfo',
NOT_CLEARANCE,
]);
// Without data the missing data wins over the ban.
expect(fact({ data: 'unavailable', bans: 1 }).icon).toBe(CircleDashed);
});
it('PCM-007: the label always ends with the note that a checklist is no clearance, once', () => {
const benches: Partial<PaddleCheck>[] = [
{},
{ data: 'none', entries: 0, open: 0 },
{ data: 'unavailable' },
{ data: 'partial', open: 0, review: 2, bans: 3 },
{ entries: 0, open: 0 },
{ open: 0, decided: 1, bans: 1 },
];
for (const bench of benches) {
const parts = lines(bench);
expect(parts[parts.length - 1]).toBe(NOT_CLEARANCE);
expect(parts.filter((part) => part === NOT_CLEARANCE)).toHaveLength(1);
}
});
});
describe('checkDataWord', () => {
it('PCM-008: data that covers the route has no word, every other coverage its own', () => {
expect(checkDataWord('covered', t)).toBeNull();
const coverages: PaddleCheckData[] = ['partial', 'none', 'limited', 'unavailable'];
for (const data of coverages) expect(checkDataWord(data, t)).toBe(`tours.paddle.check.data.${data}`);
});
});
describe('formatCheckDate', () => {
it('PCM-009: a bare month from the data reads as month and year in the language', () => {
const may = new Date(Date.UTC(2026, 4, 1));
const month = (language: string) =>
new Intl.DateTimeFormat(language, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(may);
expect(formatCheckDate('2026-05', 'en')).toBe(month('en'));
expect(formatCheckDate('2026-05', 'en')).toMatch(/May.*2026/);
expect(formatCheckDate('2026-05', 'de')).toBe(month('de'));
});
it('PCM-010: a bare day is the calendar day it names, an instant the day it falls on here', () => {
const day = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: 'UTC' }).format(
new Date(Date.UTC(2026, 9, 1))
);
expect(formatCheckDate('2026-10-01', 'en')).toBe(day);
expect(formatCheckDate('2026-10-01', 'en')).toMatch(/Oct.*1.*2026/);
const instant = '2026-10-02T04:20:00.000Z';
expect(formatCheckDate(instant, 'en')).toBe(
new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(new Date(instant))
);
});
it('PCM-011: a value that is no date, or a language Intl cannot read, comes back as written', () => {
expect(formatCheckDate('last summer', 'en')).toBe('last summer');
expect(formatCheckDate('', 'en')).toBe('');
expect(formatCheckDate('2026-05', 'not a language')).toBe('2026-05');
});
});
describe('focusPointsOf', () => {
it('PCM-012: an area small enough to frame whole gives its corners', () => {
expect(focusPointsOf(obstacleEntry())).toEqual([
[49.2, 12.005],
[49.2, 12.005],
]);
expect(focusPointsOf(ownPortage())).toEqual([
[49.2, 12.0045],
[49.2001, 12.0058],
]);
// A lock of 1.1 km is still framed whole.
expect(
focusPointsOf({
at: [49.205, 12],
bounds: [
[49.2, 12],
[49.21, 12],
],
})
).toEqual([
[49.2, 12],
[49.21, 12],
]);
});
it('PCM-013: an area too wide to frame goes to the point of the entry', () => {
// About 3.4 km from corner to corner.
expect(
focusPointsOf({
at: [49.215, 12.01],
bounds: [
[49.2, 12],
[49.23, 12.01],
],
})
).toEqual([[49.215, 12.01]]);
});
});
describe('checklistRows', () => {
it('PCM-014: lists the obstacles and the own portages and locks in route order, then the vanished ones, the earlier decisions apart', () => {
const weir = obstacleEntry();
const rapids = obstacleEntry({
key: 'node/2',
refs: ['node/2'],
kind: 'rapids',
relation: 'near',
along_m: 300,
distance_m: 40,
});
const dam = obstacleEntry({ key: 'node/3', refs: ['node/3'], kind: 'dam', along_m: 2600 });
const gone = earlierEntry();
const offRoute = earlierEntry({ key: 'node/10', refs: ['node/10'], status: 'off_route' });
const portage = ownPortage();
const lock = ownPortage({ kind: 'lock', index: 2, from_m: 2000, to_m: 2000, covers: [] });
const check = obstacleCheck({ entries: [dam, weir, gone, offRoute, rapids], own: [lock, portage] });
expect(checklistRows(check)).toEqual({
rows: [
{ type: 'obstacle', entry: rapids },
{ type: 'own', own: portage },
{ type: 'obstacle', entry: weir },
{ type: 'own', own: lock },
{ type: 'obstacle', entry: dam },
{ type: 'obstacle', entry: gone },
],
earlier: [offRoute],
});
});
it('PCM-015: an empty checklist has no rows and nothing earlier', () => {
expect(checklistRows(obstacleCheck({ entries: [], own: [] }))).toEqual({ rows: [], earlier: [] });
});
});
describe('nextPendingEntry', () => {
const decision = { decision: 'pass' as const, note: null, decided_at: '2026-10-10T09:00:00.000Z', decided_by: 5 };
const first = obstacleEntry({ along_m: 100 });
const decided = obstacleEntry({ key: 'node/2', refs: ['node/2'], along_m: 500, status: 'decided', decision });
const todo = obstacleEntry({
key: 'node/3',
refs: ['node/3'],
along_m: 900,
status: 'todo',
decision: { ...decision, decision: 'portage' },
});
const review = obstacleEntry({
key: 'node/4',
refs: ['node/4'],
along_m: 1300,
status: 'review',
changed: ['route'],
decision,
});
const last = obstacleEntry({ key: 'node/5', refs: ['node/5'], along_m: 1700, status: 'decided', decision });
const gone = earlierEntry();
const offRoute = earlierEntry({ key: 'node/11', refs: ['node/11'], status: 'off_route' });
// Listed out of route order: the next one is found by its metre, not by its place in the list.
const check = obstacleCheck({ entries: [review, last, gone, offRoute, todo, first, decided] });
it('PCM-016: skips what is decided and takes the next one after the entry, one to check again counted', () => {
expect(nextPendingEntry(check, first)).toEqual(todo);
expect(nextPendingEntry(check, decided)).toEqual(todo);
expect(nextPendingEntry(check, todo)).toEqual(review);
});
it('PCM-017: from an earlier decision outside the rows the first pending entry of the route comes next', () => {
expect(nextPendingEntry(check, offRoute)).toEqual(first);
});
it('PCM-018: a vanished object, listed after the route, comes after its last entry, and after it there is none', () => {
expect(nextPendingEntry(check, review)).toEqual(gone);
expect(nextPendingEntry(check, last)).toEqual(gone);
expect(nextPendingEntry(check, gone)).toBeNull();
expect(nextPendingEntry(obstacleCheck({ entries: [first] }), first)).toBeNull();
});
});
describe('nextPendingEntry at one metre', () => {
it('PCM-019: two pending entries at the same metre follow each other in list order', () => {
const first = obstacleEntry({ key: 'node/1', along_m: 1200 });
const second = obstacleEntry({ key: 'node/2', refs: ['node/2'], along_m: 1200 });
const check = obstacleCheck({ entries: [first, second] });
expect(nextPendingEntry(check, first)?.key).toBe('node/2');
expect(nextPendingEntry(check, second)).toBeNull();
});
});
@@ -0,0 +1,123 @@
import {
haversineMetres,
type PaddleCheck,
type PaddleCheckData,
type TourObstacleCheck,
type TourObstacleEntry,
type TourOwnEntry,
} from '@trek/shared';
import { Ban, CircleDashed, ListChecks, type LucideIcon } from 'lucide-react';
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* PAD-06 and PAD-09 on the client, without React: what the check bench of a
* paddle tour says in one pill, how its dates read, where the camera goes for
* an entry, and the order the checklist lists its rows in.
*/
/** The word for data that does not cover the whole route; null when it does. */
export function checkDataWord(data: PaddleCheckData, t: Translate): string | null {
return data === 'covered' ? null : t(`tours.paddle.check.data.${data}`);
}
/**
* The bench in one pill: the first rule that applies gives its value. Missing
* data says so and never shows a count, open entries come before a partial
* coverage, and a route without known obstacles says that, never "clear".
*/
export function paddleCheckFact(check: PaddleCheck, t: Translate): { icon: LucideIcon; value: string; label: string } {
const pending = check.open + check.review;
const word = checkDataWord(check.data, t);
let value: string;
if (check.data === 'unavailable' || check.data === 'none') value = word ?? '';
else if (pending > 0) value = t('tours.paddle.check.open', { count: pending });
else if (word) value = word;
else if (check.entries === 0) value = t('tours.paddle.check.noneKnown');
else value = t('tours.paddle.check.decided', { count: check.decided });
const noData = check.data === 'unavailable' || check.data === 'none';
const label = [
word ? `${t('tours.paddle.check.dataLabel')}: ${word}` : null,
check.open > 0 ? t('tours.paddle.check.open', { count: check.open }) : null,
check.review > 0 ? t('tours.paddle.check.review', { count: check.review }) : null,
check.decided > 0 ? t('tours.paddle.check.decided', { count: check.decided }) : null,
check.bans > 0 ? t('tours.paddle.check.bans', { count: check.bans }) : null,
check.bans > 0 ? t('tours.paddle.check.bansInfo') : null,
t('tours.paddle.check.notClearance'),
]
.filter((part): part is string => part !== null)
.join('\n');
return { icon: noData ? CircleDashed : check.bans > 0 ? Ban : ListChecks, value, label };
}
const MONTH = /^(\d{4})-(\d{2})$/;
/** A date of the checklist in the user's locale: a bare `YYYY-MM` (an OSM check date) reads as month and year. */
export function formatCheckDate(value: string, locale: string): string {
const month = MONTH.exec(value);
try {
if (month) {
const date = new Date(Date.UTC(Number(month[1]), Number(month[2]) - 1, 1));
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(date);
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
// A bare day is a calendar day, not an instant in the user's zone.
const timeZone = /^\d{4}-\d{2}-\d{2}$/.test(value) ? 'UTC' : undefined;
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone }).format(date);
} catch {
return value;
}
}
/** The widest an area may be for the camera to frame it whole; beyond, it goes to the entry's point. */
const FRAME_MAX_M = 2000;
/** Where the camera goes for an entry or an own portage: its whole extent, or its point when that is too wide. */
export function focusPointsOf(item: Pick<TourObstacleEntry, 'at' | 'bounds'>): [number, number][] {
const [south, north] = item.bounds;
const at: [number, number] = [Number(item.at[0]), Number(item.at[1])];
const corners: [number, number][] = [
[Number(south[0]), Number(south[1])],
[Number(north[0]), Number(north[1])],
];
const diagonal = haversineMetres(corners[0][0], corners[0][1], corners[1][0], corners[1][1]);
return diagonal <= FRAME_MAX_M ? corners : [at];
}
export type ChecklistRow = { type: 'obstacle'; entry: TourObstacleEntry } | { type: 'own'; own: TourOwnEntry };
/**
* The rows of a checklist in route order, the paddler's own portages and
* locks among the obstacles at their metre, then the decisions about objects
* the complete data no longer holds (they count as to check again, so they
* stay in view). Apart from them the earlier decisions the route no longer
* meets or the data cannot tell.
*/
export function checklistRows(check: TourObstacleCheck): { rows: ChecklistRow[]; earlier: TourObstacleEntry[] } {
const metreOf = (row: ChecklistRow) => (row.type === 'obstacle' ? (row.entry.along_m ?? 0) : row.own.from_m);
const onRoute: ChecklistRow[] = [
...check.entries.filter((entry) => entry.relation !== null).map((entry) => ({ type: 'obstacle' as const, entry })),
...check.own.map((own) => ({ type: 'own' as const, own })),
].sort((a, b) => metreOf(a) - metreOf(b));
const earlier = check.entries.filter((entry) => entry.relation === null);
const gone = earlier.filter((entry) => entry.status === 'gone');
return {
rows: [...onRoute, ...gone.map((entry) => ({ type: 'obstacle' as const, entry }))],
earlier: earlier.filter((entry) => entry.status !== 'gone'),
};
}
/** The statuses that still ask for a look, as the bench counts them (a vanished object among them). */
const PENDING = new Set(['open', 'todo', 'review', 'gone']);
/**
* The next entry still asking for a look, in the order the checklist lists
* its rows (route order, entries at one metre in list order, then the
* vanished ones); from an earlier decision outside the rows, the first one.
*/
export function nextPendingEntry(check: TourObstacleCheck, entry: TourObstacleEntry): TourObstacleEntry | null {
const listed = checklistRows(check).rows.flatMap((row) => (row.type === 'obstacle' ? [row.entry] : []));
const from = listed.findIndex((candidate) => candidate.key === entry.key);
return listed.slice(from + 1).find((candidate) => PENDING.has(candidate.status)) ?? null;
}
@@ -0,0 +1,56 @@
import type { PaddleObstacleKind } from '@trek/shared';
/**
* The paddler's symbols as SVG paths on lucide's 24 grid, one source for the
* React glyph and the map markers (Leaflet takes markup, not components).
*/
export type PaddleGlyphKind =
'lock' | 'weir' | 'dam' | 'rapids' | 'waterfall' | 'put_in' | 'take_out' | 'portage' | 'hazard' | 'access';
const WAVE = 'M3 18c1.5-1.5 3-1.5 4.5 0s3 1.5 4.5 0 3-1.5 4.5 0 3 1.5 4.5 0';
export const PADDLE_GLYPH_PATHS: Record<PaddleGlyphKind, readonly string[]> = {
lock: ['M3 7h18', 'M3 17h18', 'M4 7l3 5-3 5', 'M20 7l-3 5 3 5', 'M9 12h6'],
weir: ['M2 8h7c3 0 3 8 6 8h7', 'M9 8v12', 'M15 20h7'],
dam: ['M9 4h5l5 16H5z', 'M2 9h5', 'M2 13h4'],
rapids: [
'M2 7l3 2 3-2 3 2 3-2 3 2 3-2 2 1.5',
'M2 12l3 2 3-2 3 2 3-2 3 2 3-2 2 1.5',
'M2 17l3 2 3-2 3 2 3-2 3 2 3-2 2 1.5',
],
waterfall: ['M3 5h9v14', 'M15 9v10', 'M19 7v12', 'M3 19h18'],
put_in: ['M12 3v10', 'M8 9l4 4 4-4', WAVE],
take_out: ['M12 14V4', 'M8 8l4-4 4 4', WAVE],
// lucide's footprints
portage: [
'M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0Z',
'M20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0Z',
'M16 17h4',
'M4 13h4',
],
hazard: ['M12 3 2 20h20Z', 'M12 9v5', 'M12 17h.01'],
// lucide's ban
access: ['M12 2a10 10 0 1 0 0 20 10 10 0 1 0 0-20Z', 'M4.929 4.929 19.07 19.071'],
};
/** The symbol an obstacle of a kind is shown with. */
export const OBSTACLE_GLYPH: Record<PaddleObstacleKind, PaddleGlyphKind> = {
weir: 'weir',
dam: 'dam',
lock: 'lock',
lock_gate: 'lock',
waterfall: 'waterfall',
rapids: 'rapids',
hazard: 'hazard',
put_in: 'put_in',
slipway: 'put_in',
egress: 'take_out',
portage: 'portage',
access: 'access',
};
/** The symbol as SVG markup for a map marker; it carries no user content. */
export function paddleGlyphSvg(kind: PaddleGlyphKind, size: number, color: string): string {
const paths = PADDLE_GLYPH_PATHS[kind].map((d) => `<path d="${d}"/>`).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
}
@@ -0,0 +1,386 @@
// FE-PADDLE-MAP-001 to FE-PADDLE-MAP-017: the paddle objects as the planner maps draw them, one model for Leaflet and GL.
import { describe, expect, it } from 'vitest';
import {
type PaddleFeature,
type PaddleGeometry,
type PaddleObstacleKind,
paddleObstacleKindSchema,
type RelatedObstacle,
} from '@trek/shared';
import { OBSTACLE_GLYPH, PADDLE_GLYPH_PATHS, paddleGlyphSvg } from './paddleGlyphPaths';
import {
isAreaOnly,
isPaddleItemSelected,
markerSize,
PADDLE_AREA_MIN_ZOOM,
paddleItemsAtZoom,
type PaddleMapItem,
paddleMapItems,
type PaddleMapRelation,
paddleMarkerHtml,
paddleMarkerImageId,
paddleMarkerSvg,
selectedPaddleKey,
} from './paddleMapLayer';
import { paddleKindPaint, type PaddlePaint } from './paddlePaint';
const KINDS = paddleObstacleKindSchema.options;
const RELATIONS: readonly PaddleMapRelation[] = ['on_route', 'near', 'area'];
/** Test colours standing in for the theme tokens (styles/paddle.css), distinct so each can be told apart. */
const PAINT: PaddlePaint = {
obstacle: '#334155',
access: '#4338ca',
landing: '#0f766e',
portage: '#9a6233',
disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)',
};
const { obstacle: SLATE, access: INDIGO, landing: TEAL, portage: BROWN } = PAINT;
const point = (lng: number, lat: number): PaddleGeometry => ({ type: 'Point', coordinates: [lng, lat] });
function feature(ref: string, kind: PaddleObstacleKind, geometry: PaddleGeometry = point(12.1, 49.1)): PaddleFeature {
return { ref, kind, group_key: null, name: null, geometry, tags: {}, tag_hash: ref, region_id: 'oberpfalz' };
}
function related(entry: PaddleFeature, relation: RelatedObstacle['relation']): RelatedObstacle {
return { feature: entry, relation, distanceM: relation === 'on_route' ? 0 : 80, alongM: 1200 };
}
/** One object as the map draws it, standing to the route as `relation` says. */
function item(relation: PaddleMapRelation, kind: PaddleObstacleKind = 'weir', ref = 'way/1'): PaddleMapItem {
const entry = feature(ref, kind);
const [made] = paddleMapItems([entry], relation === 'area' ? [] : [related(entry, relation)], PAINT);
return made;
}
/** The marker markup as Leaflet's divIcon puts it on the page. */
function parseHtml(html: string): Element {
const host = document.createElement('div');
host.innerHTML = html;
const marker = host.firstElementChild;
if (!marker || host.children.length !== 1) throw new Error(`not one marker element: ${html}`);
return marker;
}
/** An image as a GL map decodes it: well-formed XML or nothing. */
function parseSvg(svg: string): Element {
const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
if (root.localName !== 'svg') throw new Error(`not well-formed SVG: ${svg}`);
return root;
}
function pathsOf(svg: Element): Array<string | null> {
return Array.from(svg.getElementsByTagName('path'), (path) => path.getAttribute('d'));
}
function htmlLook(html: string) {
const marker = parseHtml(html);
const glyph = marker.querySelector('svg');
return {
style: marker.getAttribute('style') ?? '',
glyph: glyph?.getAttribute('width') ?? null,
stroke: glyph?.getAttribute('stroke') ?? null,
paths: pathsOf(marker),
};
}
function svgLook(svg: string) {
const root = parseSvg(svg);
const disc = root.getElementsByTagName('circle').item(0);
const glyph = root.getElementsByTagName('svg').item(0);
return {
size: root.getAttribute('width'),
opacity: root.getAttribute('opacity'),
r: disc?.getAttribute('r') ?? null,
ring: disc?.getAttribute('stroke-width') ?? null,
dash: disc?.getAttribute('stroke-dasharray') ?? null,
glyph: glyph?.getAttribute('width') ?? null,
x: glyph?.getAttribute('x') ?? null,
};
}
describe('paddle map layer', () => {
it('FE-PADDLE-MAP-001: sorts every kind into one of four distinct colours by what the object is', () => {
const kindsIn = (color: string) => KINDS.filter((kind) => paddleKindPaint(kind, PAINT) === color);
expect(kindsIn(SLATE)).toEqual(['weir', 'dam', 'lock', 'lock_gate', 'waterfall', 'rapids', 'hazard']);
expect(kindsIn(INDIGO)).toEqual(['access']);
expect(kindsIn(TEAL)).toEqual(['put_in', 'egress', 'slipway']);
expect(kindsIn(BROWN)).toEqual(['portage']);
expect(new Set([SLATE, INDIGO, TEAL, BROWN]).size).toBe(4);
});
it('FE-PADDLE-MAP-002: draws every kind with a glyph of well-formed paths, sharing one where the paddler meets the same thing', () => {
const glyphs = new Set(KINDS.map((kind) => OBSTACLE_GLYPH[kind]));
expect(glyphs).toEqual(new Set(Object.keys(PADDLE_GLYPH_PATHS)));
expect(OBSTACLE_GLYPH.lock_gate).toBe('lock');
expect(OBSTACLE_GLYPH.slipway).toBe('put_in');
expect(OBSTACLE_GLYPH.egress).toBe('take_out');
for (const glyph of glyphs) {
expect(PADDLE_GLYPH_PATHS[glyph].length, glyph).toBeGreaterThan(0);
expect(pathsOf(parseSvg(paddleGlyphSvg(glyph, 24, SLATE))), glyph).toEqual(PADDLE_GLYPH_PATHS[glyph]);
}
for (const d of Object.values(PADDLE_GLYPH_PATHS).flat()) expect(d).toMatch(/^M/);
});
it('FE-PADDLE-MAP-003: places a point where it is, as the route relates it, and the rest of the area quietly', () => {
const weir = feature('node/1', 'weir', point(12.1, 49.1));
const slipway = feature('node/2', 'slipway', point(12.2, 49.2));
const gate = feature('node/3', 'lock_gate', point(12.3, 49.3));
const elsewhere = feature('node/4', 'dam', point(13.1, 50.1));
const items = paddleMapItems(
[weir, slipway, gate],
[related(gate, 'near'), related(weir, 'on_route'), related(elsewhere, 'on_route')],
PAINT
);
expect(items).toEqual([
{
feature: weir,
relation: 'on_route',
color: SLATE,
disc: PAINT.disc,
shadow: PAINT.shadow,
glyph: 'weir',
marker: [49.1, 12.1],
line: null,
},
{
feature: slipway,
relation: 'area',
color: TEAL,
disc: PAINT.disc,
shadow: PAINT.shadow,
glyph: 'put_in',
marker: [49.2, 12.2],
line: null,
},
{
feature: gate,
relation: 'near',
color: SLATE,
disc: PAINT.disc,
shadow: PAINT.shadow,
glyph: 'lock',
marker: [49.3, 12.3],
line: null,
},
]);
expect(items[0].feature).toBe(weir);
expect(paddleMapItems([], [related(weir, 'on_route')], PAINT)).toEqual([]);
});
it('FE-PADDLE-MAP-004: turns a line into [lat, lng] pairs and sets its marker halfway along', () => {
const weir = feature('way/1', 'weir', {
type: 'LineString',
coordinates: [
[12.1, 49.1],
[12.2, 49.2],
[12.3, 49.3],
],
});
const carry = feature('way/2', 'portage', {
type: 'LineString',
coordinates: [
[12.1, 49.1],
[12.2, 49.2],
[12.3, 49.3],
[12.4, 49.4],
],
});
const [crossing, portage] = paddleMapItems([weir, carry], [], PAINT);
expect(crossing.line).toEqual([
[49.1, 12.1],
[49.2, 12.2],
[49.3, 12.3],
]);
expect(crossing.marker).toEqual([49.2, 12.2]);
expect(portage.marker).toEqual([49.3, 12.3]);
expect(portage).toMatchObject({ relation: 'area', color: BROWN, glyph: 'portage' });
});
it('FE-PADDLE-MAP-005: outlines an area by its outer ring, and puts a shape that came without points at the origin', () => {
const lake = feature('way/1', 'access', {
type: 'Polygon',
coordinates: [
[
[12.1, 49.1],
[12.2, 49.1],
[12.2, 49.2],
[12.1, 49.2],
[12.1, 49.1],
],
[
[12.14, 49.14],
[12.16, 49.14],
[12.16, 49.16],
[12.14, 49.14],
],
],
});
const noRing = feature('way/2', 'access', { type: 'Polygon', coordinates: [] });
const noLine = feature('way/3', 'weir', { type: 'LineString', coordinates: [] });
const [area, ringless, lineless] = paddleMapItems([lake, noRing, noLine], [], PAINT);
expect(area.line).toEqual([
[49.1, 12.1],
[49.1, 12.2],
[49.2, 12.2],
[49.2, 12.1],
[49.1, 12.1],
]);
expect(area.marker).toEqual([49.2, 12.2]);
expect(area.color).toBe(INDIGO);
expect(ringless).toMatchObject({ line: [], marker: [0, 0] });
expect(lineless).toMatchObject({ line: [], marker: [0, 0] });
});
describe('at a zoom', () => {
const onRoute = item('on_route', 'weir', 'way/1');
const near = item('near', 'lock', 'way/2');
const quiet = item('area', 'put_in', 'node/3');
const picked = item('area', 'access', 'way/4');
const all = [onRoute, near, quiet, picked];
const refs = (items: readonly PaddleMapItem[]) => items.map((entry) => entry.feature.ref);
it('FE-PADDLE-MAP-006: counts only an unpicked object off the route as one of the quiet ones of the area', () => {
expect(isAreaOnly(quiet, null)).toBe(true);
expect(isAreaOnly(quiet, 'way/4')).toBe(true);
expect(isAreaOnly(picked, 'way/4')).toBe(false);
expect(isAreaOnly(onRoute, null)).toBe(false);
expect(isAreaOnly(near, null)).toBe(false);
});
it('FE-PADDLE-MAP-007: keeps an overview to the route and the picked object, and shows the whole area from the threshold on', () => {
expect(refs(paddleItemsAtZoom(all, PADDLE_AREA_MIN_ZOOM - 1, null))).toEqual(['way/1', 'way/2']);
expect(refs(paddleItemsAtZoom(all, PADDLE_AREA_MIN_ZOOM - 0.5, 'way/4'))).toEqual(['way/1', 'way/2', 'way/4']);
const zoomedIn = paddleItemsAtZoom(all, PADDLE_AREA_MIN_ZOOM, null);
expect(refs(zoomedIn)).toEqual(['way/1', 'way/2', 'node/3', 'way/4']);
expect(zoomedIn).not.toBe(all);
expect(paddleItemsAtZoom([], PADDLE_AREA_MIN_ZOOM - 1, 'way/4')).toEqual([]);
});
});
it('FE-PADDLE-MAP-008: sizes a marker by how it stands to the route, and a picked one largest of all', () => {
expect(RELATIONS.map((relation) => markerSize(relation, false))).toEqual([30, 26, 22]);
expect(RELATIONS.map((relation) => markerSize(relation, true))).toEqual([36, 36, 36]);
});
it('FE-PADDLE-MAP-009: draws a Leaflet marker solid on the route, dashed near it and faded in the area', () => {
const onRoute = htmlLook(paddleMarkerHtml(item('on_route'), false));
expect(onRoute.style).toContain('width:30px;height:30px;');
expect(onRoute.style).toContain(`border:2px solid ${SLATE};`);
expect(onRoute.style).toMatch(/opacity:1$/);
expect(onRoute).toMatchObject({ glyph: '17', stroke: SLATE, paths: PADDLE_GLYPH_PATHS.weir });
const near = htmlLook(paddleMarkerHtml(item('near', 'lock'), false));
expect(near.style).toContain('width:26px;height:26px;');
expect(near.style).toContain(`border:2px dashed ${SLATE};`);
expect(near.style).toMatch(/opacity:1$/);
expect(near).toMatchObject({ glyph: '15', stroke: SLATE, paths: PADDLE_GLYPH_PATHS.lock });
const quiet = htmlLook(paddleMarkerHtml(item('area', 'put_in'), false));
expect(quiet.style).toContain('width:22px;height:22px;');
expect(quiet.style).toContain(`border:2px solid ${TEAL};`);
expect(quiet.style).toMatch(/opacity:0\.6$/);
expect(quiet).toMatchObject({ glyph: '13', stroke: TEAL, paths: PADDLE_GLYPH_PATHS.put_in });
});
it('FE-PADDLE-MAP-010: draws a picked Leaflet marker larger, with a heavier solid ring and at full strength', () => {
for (const relation of RELATIONS) {
const picked = htmlLook(paddleMarkerHtml(item(relation, 'access'), true));
expect(picked.style, relation).toContain('width:36px;height:36px;');
expect(picked.style, relation).toContain(`border:3px solid ${INDIGO};`);
expect(picked.style, relation).toMatch(/opacity:1$/);
expect(picked.glyph, relation).toBe('21');
}
});
it('FE-PADDLE-MAP-011: draws the GL image of a marker at twice its size, the ring dashed near the route and faded in the area', () => {
const onRoute = svgLook(paddleMarkerSvg(item('on_route'), false));
const near = svgLook(paddleMarkerSvg(item('near', 'lock'), false));
const quiet = svgLook(paddleMarkerSvg(item('area', 'put_in'), false));
expect(onRoute).toEqual({ size: '60', opacity: '1', r: '26', ring: '4', dash: null, glyph: '35', x: '12.5' });
expect(near).toEqual({ size: '52', opacity: '1', r: '22', ring: '4', dash: '6 5', glyph: '30', x: '11' });
expect(quiet).toEqual({ size: '44', opacity: '0.6', r: '18', ring: '4', dash: null, glyph: '26', x: '9' });
const weir = parseSvg(paddleMarkerSvg(item('on_route'), false));
expect(weir.namespaceURI).toBe('http://www.w3.org/2000/svg');
expect(weir.getElementsByTagName('circle').item(0)?.getAttribute('stroke')).toBe(SLATE);
expect(weir.getElementsByTagName('svg').item(0)?.getAttribute('stroke')).toBe(SLATE);
expect(pathsOf(weir)).toEqual(PADDLE_GLYPH_PATHS.weir);
});
it('FE-PADDLE-MAP-012: draws a picked GL image larger and solid, every look at twice the size of its Leaflet twin', () => {
const picked = { size: '72', opacity: '1', r: '31', ring: '6', dash: null, glyph: '42', x: '15' };
for (const relation of RELATIONS) {
expect(svgLook(paddleMarkerSvg(item(relation), true)), relation).toEqual(picked);
for (const selected of [false, true]) {
const { size } = svgLook(paddleMarkerSvg(item(relation), selected));
expect(size, relation).toBe(String(markerSize(relation, selected) * 2));
}
}
});
it('FE-PADDLE-MAP-013: names one GL image per look, which two kinds that look alike share', () => {
expect(paddleMarkerImageId(item('on_route'), false)).toBe('trek-paddle-weir-334155ffffff-on_route');
expect(paddleMarkerImageId(item('on_route'), true)).toBe('trek-paddle-weir-334155ffffff-on_route-sel');
expect(paddleMarkerImageId(item('near', 'egress'), false)).toBe('trek-paddle-take_out-0f766effffff-near');
const slipway = item('area', 'slipway');
const putIn = item('area', 'put_in', 'node/9');
expect(paddleMarkerImageId(slipway, false)).toBe(paddleMarkerImageId(putIn, false));
expect(paddleMarkerSvg(slipway, false)).toBe(paddleMarkerSvg(putIn, false));
const looks = RELATIONS.flatMap((relation) =>
[false, true].map((selected) => paddleMarkerImageId(item(relation), selected))
);
expect(new Set(looks).size).toBe(6);
});
it('FE-PADDLE-MAP-014: renders a glyph as standalone SVG in the size and colour it is asked for', () => {
const glyph = parseSvg(paddleGlyphSvg('lock', 20, INDIGO));
expect(glyph.getAttribute('width')).toBe('20');
expect(glyph.getAttribute('height')).toBe('20');
expect(glyph.getAttribute('viewBox')).toBe('0 0 24 24');
expect(glyph.getAttribute('fill')).toBe('none');
expect(glyph.getAttribute('stroke')).toBe(INDIGO);
expect(pathsOf(glyph)).toEqual(PADDLE_GLYPH_PATHS.lock);
});
it('FE-PADDLE-MAP-015: hands the map no part of the object data, so a hostile name in OSM cannot reach the markup', () => {
const hostile = '<img src=x onerror=alert(1)>';
const entry = { ...feature('way/1', 'weir'), name: hostile, tags: { name: hostile, note: hostile } };
const [drawn] = paddleMapItems([entry], [related(entry, 'near')], PAINT);
const markup = [false, true].flatMap((selected) => [
paddleMarkerHtml(drawn, selected),
paddleMarkerSvg(drawn, selected),
paddleMarkerImageId(drawn, selected),
]);
for (const text of markup) expect(text).not.toContain('onerror');
expect(parseHtml(paddleMarkerHtml(drawn, true)).querySelector('img')).toBeNull();
});
});
describe('the selected lock (PAD-06)', () => {
const chamber = { ...feature('way/7', 'lock'), group_key: 'lock:node/3' };
const gate = { ...feature('node/3', 'lock_gate'), group_key: 'lock:node/3' };
const weir = feature('node/9', 'weir');
const items = paddleMapItems([chamber, gate, weir], [], PAINT);
it('FE-PADDLE-MAP-016: the key of the selected object is its lock, or its own ref, and nothing without a selection', () => {
expect(selectedPaddleKey([chamber, gate, weir], 'way/7')).toBe('lock:node/3');
expect(selectedPaddleKey([chamber, gate, weir], 'node/9')).toBe('node/9');
expect(selectedPaddleKey([chamber, gate, weir], 'node/404')).toBeNull();
expect(selectedPaddleKey([chamber, gate, weir], null)).toBeNull();
});
it('FE-PADDLE-MAP-017: every part of a selected lock shows as selected, another object does not', () => {
const selected = items.filter((item) => isPaddleItemSelected(item, 'way/7', 'lock:node/3'));
expect(selected.map((item) => item.feature.ref)).toEqual(['way/7', 'node/3']);
expect(items.filter((item) => isPaddleItemSelected(item, 'node/9', 'node/9')).map((i) => i.feature.ref)).toEqual([
'node/9',
]);
expect(items.some((item) => isPaddleItemSelected(item, null, null))).toBe(false);
});
});
@@ -0,0 +1,157 @@
import { paddleObstacleKeyOf, type PaddleFeature, type RelatedObstacle } from '@trek/shared';
import { OBSTACLE_GLYPH, PADDLE_GLYPH_PATHS, paddleGlyphSvg, type PaddleGlyphKind } from './paddleGlyphPaths';
import { paddleKindPaint, type PaddlePaint } from './paddlePaint';
/**
* PAD-05B: the paddle obstacles as the planner maps draw them, one model for
* Leaflet and GL. Colours sort objects into what they are, never into safe or
* unsafe: obstacles in slate, access rules in indigo, places to get in and out
* in teal, portage ways in the earth brown of the planner's own portages.
* How strongly an object shows says how it stands to the route.
*/
export type PaddleMapRelation = 'on_route' | 'near' | 'area';
export interface PaddleMapItem {
feature: PaddleFeature;
relation: PaddleMapRelation;
/** Its kind's colour, resolved for map paint. */
color: string;
/** The marker's disc and the lines' casing, in the map's own white (or dark). */
disc: string;
shadow: string;
glyph: PaddleGlyphKind;
/** Where its marker sits: the point itself, or the middle of its line. */
marker: [number, number];
/** Its line or outline as [lat, lng] pairs; null for a point. */
line: [number, number][] | null;
}
export interface PaddleMapLayer {
items: PaddleMapItem[];
selectedRef: string | null;
/** The checklist key of the selected object (PAD-06): every part of a selected lock lights up with it. */
selectedKey: string | null;
onSelect: (ref: string) => void;
}
/** The checklist key of the selected object among the features, or null when none is selected. */
export function selectedPaddleKey(features: readonly PaddleFeature[], selectedRef: string | null): string | null {
const feature = selectedRef === null ? undefined : features.find((candidate) => candidate.ref === selectedRef);
return feature ? paddleObstacleKeyOf(feature) : null;
}
/** Whether an item shows as selected: the object itself, or another part of the same lock. */
export function isPaddleItemSelected(
item: PaddleMapItem,
selectedRef: string | null,
selectedKey: string | null
): boolean {
return (
item.feature.ref === selectedRef || (selectedKey !== null && paddleObstacleKeyOf(item.feature) === selectedKey)
);
}
function lineOf(feature: PaddleFeature): [number, number][] | null {
const { geometry } = feature;
if (geometry.type === 'Point') return null;
const ring = geometry.type === 'LineString' ? geometry.coordinates : (geometry.coordinates[0] ?? []);
return ring.map(([lng, lat]) => [lat, lng]);
}
function markerOf(feature: PaddleFeature, line: [number, number][] | null): [number, number] {
if (feature.geometry.type === 'Point') return [feature.geometry.coordinates[1], feature.geometry.coordinates[0]];
const points = line ?? [];
return points[Math.floor(points.length / 2)] ?? [0, 0];
}
/** The items to draw: the related ones as they stand to the route, the rest of the area quietly. */
export function paddleMapItems(
features: readonly PaddleFeature[],
related: readonly RelatedObstacle[],
paint: PaddlePaint
): PaddleMapItem[] {
const relations = new Map(related.map((entry) => [entry.feature.ref, entry.relation]));
return features.map((feature) => {
const line = lineOf(feature);
return {
feature,
relation: relations.get(feature.ref) ?? 'area',
color: paddleKindPaint(feature.kind, paint),
disc: paint.disc,
shadow: paint.shadow,
glyph: OBSTACLE_GLYPH[feature.kind],
marker: markerOf(feature, line),
line,
};
});
}
/**
* Objects that are only in the area show from this zoom on, on Leaflet and GL
* alike, so an overview of a long tour stays calm; the ones on or near the
* route always show, and so does a picked one.
*/
export const PADDLE_AREA_MIN_ZOOM = 13;
/** Whether an item is drawn as one of the area's quiet objects, which wait for PADDLE_AREA_MIN_ZOOM. */
export function isAreaOnly(item: PaddleMapItem, selectedRef: string | null): boolean {
return item.relation === 'area' && item.feature.ref !== selectedRef;
}
/** The items a map at `zoom` shows. */
export function paddleItemsAtZoom(
items: readonly PaddleMapItem[],
zoom: number,
selectedRef: string | null
): PaddleMapItem[] {
return zoom >= PADDLE_AREA_MIN_ZOOM ? [...items] : items.filter((item) => !isAreaOnly(item, selectedRef));
}
/** The marker's size in pixels by how it stands to the route; a selected one is larger still. */
export function markerSize(relation: PaddleMapRelation, selected: boolean): number {
if (selected) return 36;
return relation === 'on_route' ? 30 : relation === 'near' ? 26 : 22;
}
/**
* A marker as markup: a white disc with a ring in the object's colour and its
* glyph inside, dashed when the object is only near the route, faded when it
* is merely in the area. It carries no user content.
*/
export function paddleMarkerHtml(item: PaddleMapItem, selected: boolean): string {
const size = markerSize(item.relation, selected);
const ring = selected ? 3 : 2;
const dash = item.relation === 'near' && !selected ? 'dashed' : 'solid';
const opacity = item.relation === 'area' && !selected ? 0.6 : 1;
const glyph = paddleGlyphSvg(item.glyph, Math.round(size * 0.58), item.color);
const style = `width:${size}px;height:${size}px;border-radius:9999px;background:${item.disc};border:${ring}px ${dash} ${item.color};box-shadow:0 1px 3px ${item.shadow};display:grid;place-items:center;box-sizing:border-box;opacity:${opacity}`;
return `<div style="${style}">${glyph}</div>`;
}
/**
* The same marker as a standalone SVG, for the GL maps' symbol images: drawn
* at twice its size for sharp pixels on dense screens, the ring dashed or
* faded as the HTML marker is.
*/
export function paddleMarkerSvg(item: PaddleMapItem, selected: boolean): string {
const size = markerSize(item.relation, selected) * 2;
const ring = (selected ? 3 : 2) * 2;
const r = size / 2 - ring / 2 - 2;
const dash = item.relation === 'near' && !selected ? ' stroke-dasharray="6 5"' : '';
const opacity = item.relation === 'area' && !selected ? 0.6 : 1;
const glyph = Math.round(size * 0.58);
const offset = (size - glyph) / 2;
const paths = PADDLE_GLYPH_PATHS[item.glyph].map((d) => `<path d="${d}"/>`).join('');
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" opacity="${opacity}">` +
`<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="${item.disc}" stroke="${item.color}" stroke-width="${ring}"${dash}/>` +
`<svg x="${offset}" y="${offset}" width="${glyph}" height="${glyph}" viewBox="0 0 24 24" fill="none" stroke="${item.color}" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">${paths}</svg></svg>`
);
}
/** The name of a marker's image on a GL map, one per look; a theme change draws new ones. */
export function paddleMarkerImageId(item: PaddleMapItem, selected: boolean): string {
const tint = `${item.color}-${item.disc}`.replace(/[^a-z0-9]/gi, '');
return `trek-paddle-${item.glyph}-${tint}-${item.relation}${selected ? '-sel' : ''}`;
}
@@ -0,0 +1,213 @@
// FE-PADDLE-OBSMODEL-001 to FE-PADDLE-OBSMODEL-016: what the obstacle card and the route band show of a paddle object.
import { describe, expect, it } from 'vitest';
import {
paddleObstacleKindSchema,
type PaddleCoverageRegion,
type PaddleFeature,
type RelatedObstacle,
} from '@trek/shared';
import {
isBlockingKind,
metresOf,
OBSTACLE_FACT_KEYS,
osmUrlOf,
paddleObstacleDetail,
routeStripObstacles,
} from './paddleObstacleModel';
/** A weir in the Upper Palatinate unless told otherwise. */
function feature(ref: string, overrides: Partial<PaddleFeature> = {}): PaddleFeature {
return {
ref,
kind: 'weir',
group_key: null,
name: null,
geometry: { type: 'Point', coordinates: [12.1301, 49.1231] },
tags: {},
tag_hash: ref,
region_id: 'oberpfalz',
...overrides,
};
}
/** How the route meets an object: on it where it crosses, or near it, 80 m off. */
function relate(target: PaddleFeature, relation: RelatedObstacle['relation'], alongM: number): RelatedObstacle {
return { feature: target, relation, distanceM: relation === 'on_route' ? 0 : 80, alongM };
}
function region(id: string, sourceTimestamp: string | null): PaddleCoverageRegion {
return { id, name: id, source_timestamp: sourceTimestamp, imported_at: '2026-10-02T06:00:00Z' };
}
/** The card's view of one picked object among the loaded ones. */
function detailOf(
picked: PaddleFeature,
context: { related?: RelatedObstacle[]; regions?: PaddleCoverageRegion[] } = {}
) {
return paddleObstacleDetail({
features: [feature('node/999'), picked],
related: context.related ?? [],
regions: context.regions ?? [],
selectedRef: picked.ref,
});
}
describe('osmUrlOf', () => {
it('FE-PADDLE-OBSMODEL-001: links the object in OpenStreetMap by its own reference', () => {
expect(osmUrlOf('way/29256405')).toBe('https://www.openstreetmap.org/way/29256405');
expect(osmUrlOf('node/17')).toBe('https://www.openstreetmap.org/node/17');
});
});
describe('isBlockingKind', () => {
it('FE-PADDLE-OBSMODEL-003: counts obstacles and access rules, never the places and ways that help', () => {
const blocking = paddleObstacleKindSchema.options.filter((kind) => isBlockingKind(kind));
const helping = paddleObstacleKindSchema.options.filter((kind) => !isBlockingKind(kind));
expect(blocking).toEqual(['weir', 'dam', 'lock', 'lock_gate', 'waterfall', 'rapids', 'hazard', 'access']);
expect(helping).toEqual(['put_in', 'egress', 'slipway', 'portage']);
});
});
describe('paddleObstacleDetail', () => {
it('FE-PADDLE-OBSMODEL-004: knows nothing when nothing is picked or the picked object is no longer loaded', () => {
const state = { features: [feature('node/1')], related: [], regions: [] };
expect(paddleObstacleDetail({ ...state, selectedRef: null })).toBeNull();
expect(paddleObstacleDetail({ ...state, selectedRef: '' })).toBeNull();
expect(paddleObstacleDetail({ ...state, selectedRef: 'node/2' })).toBeNull();
});
it('FE-PADDLE-OBSMODEL-005: leaves an object only in the area in the area, without a distance or a place on the route', () => {
const weir = feature('way/7', { name: 'Wehr Lindenmühle' });
const detail = detailOf(weir, { related: [relate(feature('way/8'), 'on_route', 300)] });
expect(detail).toMatchObject({ feature: weir, relation: 'area', distanceM: null, alongM: null });
});
it('FE-PADDLE-OBSMODEL-006: carries how its own related entry stands to the route, a crossing at the start included', () => {
const weir = feature('way/7');
const crossed = detailOf(weir, { related: [relate(weir, 'on_route', 0)] });
expect(crossed).toMatchObject({ relation: 'on_route', distanceM: 0, alongM: 0 });
const passed = detailOf(weir, { related: [relate(feature('way/8'), 'near', 40), relate(weir, 'near', 1200)] });
expect(passed).toMatchObject({ relation: 'near', distanceM: 80, alongM: 1200 });
});
it('FE-PADDLE-OBSMODEL-007: dates the data by the region the object came from, and leaves the date unknown otherwise', () => {
const weir = feature('way/7');
const regions = [region('bayern', '2026-09-01T00:00:00Z'), region('oberpfalz', '2026-10-01T21:14:02Z')];
expect(detailOf(weir, { regions })?.dataAsOf).toBe('2026-10-01T21:14:02Z');
expect(detailOf(weir, { regions: [region('oberpfalz', null)] })?.dataAsOf).toBeNull();
expect(detailOf(weir, { regions: [region('bayern', '2026-09-01T00:00:00Z')] })?.dataAsOf).toBeNull();
expect(detailOf(weir)?.dataAsOf).toBeNull();
});
it('FE-PADDLE-OBSMODEL-008: reads the access rule canoe first with its condition, and an untagged object has none', () => {
const rule = feature('way/7', {
kind: 'access',
tags: { canoe: 'no', boat: 'yes', 'canoe:conditional': 'yes @ (Jun-Sep)' },
});
expect(detailOf(rule)?.access).toEqual({
value: 'no',
source: 'canoe',
condition: 'yes @ (Jun-Sep)',
forbidden: true,
private: false,
});
expect(detailOf(feature('way/8'))?.access).toEqual({
value: null,
source: null,
condition: null,
forbidden: false,
private: false,
});
});
it('FE-PADDLE-OBSMODEL-009: calls a carrying way private on a private tag of its own or of the way, and nothing else', () => {
const carryWay = (tags: Record<string, string>) => detailOf(feature('way/9', { kind: 'portage', tags }));
expect(carryWay({ access: 'private' })?.privateWay).toBe(true);
expect(carryWay({ canoe: 'yes', 'portage:access': 'private' })?.privateWay).toBe(true);
expect(carryWay({ canoe: 'yes', access: 'private' })?.privateWay).toBe(true);
// The carrying way's own tag speaks over the general one.
expect(carryWay({ canoe: 'yes', access: 'private', 'portage:access': 'yes' })?.privateWay).toBe(false);
expect(carryWay({})?.privateWay).toBe(false);
// Only a carrying way is a way round: a private weir is still an obstacle, not a private way.
expect(detailOf(feature('way/10', { tags: { access: 'private' } }))?.privateWay).toBe(false);
});
it('FE-PADDLE-OBSMODEL-010: names the facts it has a label for in its own order, trimmed, and leaves the rest behind', () => {
const lock = feature('way/11', {
kind: 'lock',
tags: {
operator: ' WSA Donau-MDK ',
lock: 'yes',
height: '2.5',
fee: 'yes',
seasonal: ' ',
'whitewater:section_grade': 'II',
width: '',
opening_hours: 'Mo-Su 08:00-18:00',
},
});
expect(detailOf(lock)?.facts).toEqual([
{ key: 'height', value: '2.5' },
{ key: 'whitewater:section_grade', value: 'II' },
{ key: 'opening_hours', value: 'Mo-Su 08:00-18:00' },
{ key: 'fee', value: 'yes' },
{ key: 'operator', value: 'WSA Donau-MDK' },
]);
const everything = Object.fromEntries(OBSTACLE_FACT_KEYS.map((key): [string, string] => [key, '1']));
const named = detailOf(feature('way/12', { tags: everything }))?.facts.map((fact) => fact.key);
expect(named).toEqual([...OBSTACLE_FACT_KEYS]);
expect(detailOf(feature('way/13'))?.facts).toEqual([]);
});
it('FE-PADDLE-OBSMODEL-011: shows the description before the note, as written, and no note when both are empty', () => {
const noteOf = (tags: Record<string, string>) => detailOf(feature('way/14', { tags }))?.note;
expect(noteOf({ description: ' Portage path ', note: 'Steps are slippery' })).toBe('Portage path');
expect(noteOf({ description: ' ', note: ' Steps are slippery ' })).toBe('Steps are slippery');
expect(noteOf({ note: '' })).toBeNull();
expect(noteOf({})).toBeNull();
});
});
describe('routeStripObstacles', () => {
it('FE-PADDLE-OBSMODEL-012: places each obstacle by its share of the measured route on a band of another length', () => {
const weir = feature('way/1', { name: 'Wehr Lindenmühle' });
const lock = feature('way/2', { kind: 'lock' });
const strip = routeStripObstacles([relate(weir, 'on_route', 500), relate(lock, 'near', 1500)], 2000, 3000);
expect(strip).toEqual([
{ ref: 'way/1', kind: 'weir', name: 'Wehr Lindenmühle', relation: 'on_route', atM: 750 },
{ ref: 'way/2', kind: 'lock', name: null, relation: 'near', atM: 2250 },
]);
});
it('FE-PADDLE-OBSMODEL-013: marks only what a paddler decides about there, leaving access rules to the map and the card', () => {
const all = paddleObstacleKindSchema.options.map((kind) => relate(feature(kind, { kind }), 'near', 100));
const kinds = routeStripObstacles(all, 1000, 1000).map((entry) => entry.kind);
expect(kinds).toEqual(['weir', 'dam', 'lock', 'lock_gate', 'waterfall', 'rapids', 'hazard']);
});
it('FE-PADDLE-OBSMODEL-014: keeps an object before the start or past the measured end on the band', () => {
const before = relate(feature('way/1'), 'near', -40);
const past = relate(feature('way/2'), 'on_route', 2600);
expect(routeStripObstacles([before, past], 2500, 2000).map((entry) => entry.atM)).toEqual([0, 2000]);
});
it('FE-PADDLE-OBSMODEL-015: marks nothing on a route or a band without a length', () => {
const related = [relate(feature('way/1'), 'on_route', 100)];
expect(routeStripObstacles(related, 0, 1000)).toEqual([]);
expect(routeStripObstacles(related, -5, 1000)).toEqual([]);
expect(routeStripObstacles(related, 1000, 0)).toEqual([]);
expect(routeStripObstacles([], 1000, 1000)).toEqual([]);
});
});
describe('metresOf', () => {
it('FE-PADDLE-OBSMODEL-016: reads a bare number as metres and leaves units and words to be shown as written', () => {
expect(metresOf('2')).toBe(2);
expect(metresOf('1.5')).toBe(1.5);
expect(metresOf('0.75')).toBe(0.75);
const worded = ['2 m', '1,5', '6 ft', 'about 3', '.5', '1.', '-1', ' 2', ''];
expect(worded.map((value) => metresOf(value))).toEqual(worded.map(() => null));
});
});
@@ -0,0 +1,123 @@
import {
PADDLE_BLOCKING_KINDS,
paddleAccessOf,
type PaddleAccess,
type PaddleCoverageRegion,
type PaddleFeature,
type PaddleObstacleKind,
type RelatedObstacle,
} from '@trek/shared';
import type { PaddleMapRelation } from './paddleMapLayer';
/**
* PAD-05B: what the obstacle card and the route band show of one paddle
* object, worked out of the obstacle store without React. Nothing here turns
* a missing value into a permission: an object without an access tag says it
* has none, and an object only near the route stays "near", never "on it".
*/
/** The tags the card names, each with a label of its own; everything else of the kept tags stays behind. */
export const OBSTACLE_FACT_KEYS = [
'height',
'width',
'whitewater:section_grade',
'opening_hours',
'seasonal',
'fee',
'operator',
] as const;
type ObstacleFactKey = (typeof OBSTACLE_FACT_KEYS)[number];
/** The access values the card puts into words; any other value is shown as OSM has it. */
export const KNOWN_ACCESS_VALUES = ['yes', 'no', 'private', 'permissive', 'designated'] as const;
export interface PaddleObstacleDetail {
feature: PaddleFeature;
relation: PaddleMapRelation;
/** Metres between the route and the object; null for an object only in the area. */
distanceM: number | null;
/** Metres from the start of the route to where it comes closest; null for an object only in the area. */
alongM: number | null;
/** The date the object's region is current to, ISO 8601; null when unknown. */
dataAsOf: string | null;
access: PaddleAccess;
/** A carrying way someone recorded as private: never a way round that is open to anyone. */
privateWay: boolean;
facts: { key: ObstacleFactKey; value: string }[];
/** The free text OSM holds about it, shown as written. */
note: string | null;
}
/** The object in OpenStreetMap, for the source link. */
export function osmUrlOf(ref: string): string {
return `https://www.openstreetmap.org/${ref}`;
}
export function isBlockingKind(kind: PaddleObstacleKind): boolean {
return PADDLE_BLOCKING_KINDS.includes(kind);
}
/** The picked object with everything the card says about it, or null when nothing (or nothing known) is picked. */
export function paddleObstacleDetail(state: {
features: readonly PaddleFeature[];
related: readonly RelatedObstacle[];
regions: readonly PaddleCoverageRegion[];
selectedRef: string | null;
}): PaddleObstacleDetail | null {
const feature = state.selectedRef ? state.features.find((entry) => entry.ref === state.selectedRef) : undefined;
if (!feature) return null;
const related = state.related.find((entry) => entry.feature.ref === feature.ref);
const tags = feature.tags;
const access = paddleAccessOf(tags);
const portageAccess = tags['portage:access'] ?? tags.access;
return {
feature,
relation: related?.relation ?? 'area',
distanceM: related ? related.distanceM : null,
alongM: related ? related.alongM : null,
dataAsOf: state.regions.find((region) => region.id === feature.region_id)?.source_timestamp ?? null,
access,
privateWay: feature.kind === 'portage' && (access.private || portageAccess === 'private'),
facts: OBSTACLE_FACT_KEYS.flatMap((key) => {
const value = tags[key]?.trim();
return value ? [{ key, value }] : [];
}),
note: tags.description?.trim() || tags.note?.trim() || null,
};
}
export interface RouteStripObstacle {
ref: string;
kind: PaddleObstacleKind;
name: string | null;
relation: 'on_route' | 'near';
/** Where it sits on the band, in the band's own metres. */
atM: number;
}
/**
* The obstacles to mark on the route band: the ones a paddler has to decide
* about, on the route or near it, placed by their share of the measured route
* so they sit right on a band drawn to another length. Access rules run along
* whole stretches and are left to the map and the card.
*/
export function routeStripObstacles(
related: readonly RelatedObstacle[],
measuredLengthM: number,
bandLengthM: number
): RouteStripObstacle[] {
if (measuredLengthM <= 0 || bandLengthM <= 0) return [];
return related.flatMap((entry) => {
const { feature } = entry;
if (feature.kind === 'access' || !isBlockingKind(feature.kind)) return [];
const share = Math.min(1, Math.max(0, entry.alongM / measuredLengthM));
return [
{ ref: feature.ref, kind: feature.kind, name: feature.name, relation: entry.relation, atM: share * bandLengthM },
];
});
}
/** A length tag as a number of metres when it is one ("1.5", "2"); null for anything with its own unit or words. */
export function metresOf(value: string): number | null {
return /^\d+(\.\d+)?$/.test(value) ? Number(value) : null;
}
@@ -0,0 +1,53 @@
import type { PaddleObstacleKind } from '@trek/shared';
import { useMapPaint } from '../../Map/mapPaint';
/**
* The colours of paddle objects and carried stretches (styles/paddle.css):
* a kind of object has its colour, never one for safe or unsafe. Map paint
* reads them resolved (`usePaddlePaint`); markup on the page takes the CSS
* variable itself (`paddleKindCss`), which follows the theme on its own.
*/
const PADDLE_PAINT_TOKENS = {
obstacle: '--map-paddle-obstacle',
access: '--map-paddle-access',
landing: '--map-paddle-landing',
portage: '--map-portage',
disc: '--map-paddle-disc',
shadow: '--map-paddle-shadow',
} as const;
export type PaddlePaint = Record<keyof typeof PADDLE_PAINT_TOKENS, string>;
type Category = 'obstacle' | 'access' | 'landing' | 'portage';
const CATEGORY: Record<PaddleObstacleKind, Category> = {
weir: 'obstacle',
dam: 'obstacle',
lock: 'obstacle',
lock_gate: 'obstacle',
waterfall: 'obstacle',
rapids: 'obstacle',
hazard: 'obstacle',
access: 'access',
put_in: 'landing',
egress: 'landing',
slipway: 'landing',
portage: 'portage',
};
/** The resolved colour of a kind, for map paint. */
export function paddleKindPaint(kind: PaddleObstacleKind, paint: PaddlePaint): string {
return paint[CATEGORY[kind]];
}
/** The colour of a kind as a CSS variable, for markup on the page (the card, the route band). */
export function paddleKindCss(kind: PaddleObstacleKind): string {
return `var(${PADDLE_PAINT_TOKENS[CATEGORY[kind]]})`;
}
/** A carried stretch's brown, for markup on the page. */
export const PORTAGE_CSS_COLOR = `var(${PADDLE_PAINT_TOKENS.portage})`;
/** The paddle colours resolved for map paint, again whenever the theme changes. */
export function usePaddlePaint(): PaddlePaint {
return useMapPaint(PADDLE_PAINT_TOKENS);
}
@@ -0,0 +1,801 @@
/**
* UPC-001 to UPC-032: the obstacle checklist of the paddle tour open in the
* tours workspace (PAD-06, PAD-09), as its card and the map share it.
*
* Pinned: off for anything but an open paddle tour in an active workspace,
* new for a paddle tour not saved yet; the checklist read for the bench the
* list shows, again only when its revision or the connection changes, and an
* answer for an older revision dropped. A decision shows at once, takes in
* the server's answer and is undone with a toast when the save fails;
* deciding waits while the route has unsaved changes and is never offered to
* someone who may not change the tour. Selecting an entry moves the map onto
* it, or opens its dialog where the map does not hold it; planning a portage
* starts the track's own pick or selects the control point to carry from.
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PaddleFeature, TourDetailResponse, TourListItem, TripMember } from '@trek/shared';
import { buildUser } from '../../../../tests/helpers/factories';
import {
earlierEntry,
obstacleCheck,
obstacleEntry,
ownPortage,
paddleBench,
ROUTE_BASIS,
SOURCE_BASIS,
} from '../../../../tests/helpers/paddleCheck';
import { seedStore } from '../../../../tests/helpers/store';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import type { tourRepo } from '../../../repo/tourRepo';
import { useAuthStore } from '../../../store/authStore';
import {
clearPaddleObstacles,
publishPaddleObstacles,
selectPaddleObstacle,
usePaddleObstacleStore,
} from '../../../store/paddleObstacleStore';
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
import { usePaddleChecklist } from './usePaddleChecklist';
type CheckRead = Awaited<ReturnType<typeof tourRepo.obstacleCheck>>;
type Options = Parameters<typeof usePaddleChecklist>[0];
type TrackPlan = NonNullable<Options['trackPaddlePlan']>;
const repo = vi.hoisted(() => ({
obstacleCheck: vi.fn<typeof tourRepo.obstacleCheck>(),
saveObstacleReviews: vi.fn<typeof tourRepo.saveObstacleReviews>(),
}));
const network = vi.hoisted(() => ({ offline: false }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: repo }));
vi.mock('../../../hooks/useNetworkMode', () => ({
useNetworkMode: () => ({ offline: network.offline, forced: false, setForced: vi.fn() }),
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const ENTRY = obstacleEntry();
const CHECK = obstacleCheck({ entries: [ENTRY] });
const BASIS = { route: ROUTE_BASIS, source: SOURCE_BASIS };
const MEMBERS: TripMember[] = [
{ id: 4, username: 'maria' },
{ id: 5, username: 'jonas' },
];
/** A kayak tour of the trip with its bench: the weir of the fixtures still open. */
function paddleTour(overrides: Partial<TourListItem> = {}): TourListItem {
return {
place_id: 7,
name: 'Regen from Roding',
tour_type: 'kayak',
distance: 5,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: null,
planned: false,
caution: false,
boat_type: 'kayak',
water_context: 'river',
paddle_check: paddleBench(),
...overrides,
};
}
const TOUR = paddleTour();
/** Three control points due north, 1112 m apart. */
const WAYPOINTS: TourPlannerWaypoint[] = [
{ id: 'w1', lat: 49.2, lng: 12, role: 'start' },
{ id: 'w2', lat: 49.21, lng: 12, role: 'via' },
{ id: 'w3', lat: 49.22, lng: 12, role: 'end' },
];
const setSelectedWaypointId = vi.fn();
/** The planner with the saved paddle tour open for editing and nothing unsaved, unless told otherwise. */
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
const stub: Partial<TourPlannerController> = {
mode: { type: 'edit-saved', placeId: 7 },
isPaddle: true,
hasUnsavedChanges: false,
waypoints: WAYPOINTS,
setSelectedWaypointId,
...overrides,
};
return stub as TourPlannerController;
}
/** The planner with the paddle tour open read-only as a recorded track. */
function recorded(): TourPlannerController {
return planner({ mode: { type: 'view-gpx', placeId: 7, tour: TOUR } });
}
function trackPlan(overrides: Partial<TrackPlan> = {}): TrackPlan {
return { dirty: false, pick: null, startPick: vi.fn(), ...overrides };
}
function options(overrides: Partial<Options> = {}): Options {
return {
tripId: 3,
planner: planner(),
tours: [TOUR],
tripMembers: MEMBERS,
canEdit: true,
active: true,
onSaved: vi.fn(),
...overrides,
};
}
function renderChecklist(props: Options = options()) {
return renderHook((current: Options) => usePaddleChecklist(current), {
initialProps: props,
wrapper: TranslationProvider,
});
}
/** Renders the hook and waits for the checklist to be there. */
async function renderReady(props: Options = options()) {
const view = renderChecklist(props);
await waitFor(() => expect(view.result.current.status).toBe('ready'));
return view;
}
/** A read or a save the test answers by hand, so the hook can be read while it is on its way. */
function deferred<T>() {
let resolve: (value: T) => void = () => undefined;
let reject: (reason: Error) => void = () => undefined;
const promise = new Promise<T>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
/** Lets a read the last render started settle inside act. */
async function flush() {
await act(async () => {
await Promise.resolve();
});
}
/** An object the map holds, at the point of the weir. */
function feature(ref: string): PaddleFeature {
return {
ref,
kind: 'weir',
group_key: null,
name: null,
geometry: { type: 'Point', coordinates: [12.005, 49.2] },
tags: {},
tag_hash: ref,
region_id: 'oberpfalz',
};
}
/** The objects around the route as the sync hook publishes them. */
function publishMap(refs: string[]) {
publishPaddleObstacles({
status: 'ready',
features: refs.map((ref) => feature(ref)),
related: [],
routeLengthM: 5000,
coverage: 'covered',
regions: [],
truncated: false,
});
}
const round = (value: number) => Math.round(value * 1e6) / 1e6;
/** Where the camera was last sent, rounded past the floating point of the margin. */
function lastFocus() {
const current = usePaddleObstacleStore.getState().focus;
if (!current) return null;
return { seq: current.seq, points: current.points.map(([lat, lng]) => [round(lat), round(lng)]) };
}
/** The point of the weir framed with the margin of the store, 0.0015 degrees. */
const WEIR_FRAME = [
[49.1985, 12.0035],
[49.2015, 12.0065],
];
beforeEach(() => {
vi.clearAllMocks();
repo.obstacleCheck.mockReset();
repo.saveObstacleReviews.mockReset();
repo.obstacleCheck.mockResolvedValue({ check: CHECK, stale: false });
network.offline = false;
clearPaddleObstacles();
seedStore(useAuthStore, { user: buildUser({ id: 4 }) });
});
describe('usePaddleChecklist: what it reads', () => {
it('UPC-001: is off for a hike, a neutral planner and a new tour that is no paddle tour, and reads nothing', () => {
const hike = paddleTour({ tour_type: 'hike', boat_type: null, water_context: null, paddle_check: null });
const { result, rerender } = renderChecklist(options({ tours: [hike], planner: planner({ isPaddle: false }) }));
expect(result.current).toMatchObject({ status: 'off', tour: null, check: null, summary: null, canDecide: false });
rerender(options({ planner: planner({ mode: { type: 'neutral' } }) }));
expect(result.current).toMatchObject({ status: 'off', tour: null });
rerender(options({ planner: planner({ mode: { type: 'new-draft' }, isPaddle: false }) }));
expect(result.current.status).toBe('off');
expect(repo.obstacleCheck).not.toHaveBeenCalled();
});
it('UPC-002: is off while the workspace is not active, and reads once it is', async () => {
const { result, rerender } = renderChecklist(options({ active: false }));
expect(result.current).toMatchObject({ status: 'off', check: null, canDecide: false });
rerender(options({ active: false, planner: planner({ mode: { type: 'new-draft' } }) }));
expect(result.current.status).toBe('off');
expect(repo.obstacleCheck).not.toHaveBeenCalled();
rerender(options());
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(repo.obstacleCheck).toHaveBeenCalledTimes(1);
});
it('UPC-003: a paddle tour not saved yet is new, with nothing to read', () => {
const { result } = renderChecklist(options({ planner: planner({ mode: { type: 'new-draft' } }) }));
expect(result.current).toMatchObject({ status: 'new', tour: null, check: null, summary: null, canDecide: false });
expect(repo.obstacleCheck).not.toHaveBeenCalled();
});
it('UPC-004: loads the checklist for the bench the list shows, then has it ready to decide', async () => {
const read = deferred<CheckRead>();
repo.obstacleCheck.mockReturnValueOnce(read.promise);
const { result } = renderChecklist();
expect(result.current).toMatchObject({
status: 'loading',
tour: TOUR,
check: null,
summary: TOUR.paddle_check,
canDecide: false,
});
expect(repo.obstacleCheck).toHaveBeenCalledWith(3, 7, 'rev-1', expect.any(AbortSignal));
await act(async () => {
read.resolve({ check: CHECK, stale: false });
await read.promise;
});
expect(result.current).toMatchObject({
status: 'ready',
check: CHECK,
summary: TOUR.paddle_check,
stale: false,
dirty: false,
canDecide: true,
savingKey: null,
selectedKey: null,
dialogKey: null,
});
});
it('UPC-005: a recorded track not in the list yet is read through the tour the planner holds, and a cached answer for an older bench reads as stale', async () => {
repo.obstacleCheck.mockResolvedValueOnce({ check: CHECK, stale: true });
const { result } = await renderReady(options({ planner: recorded(), tours: [] }));
expect(result.current).toMatchObject({ tour: TOUR, check: CHECK, stale: true });
expect(repo.obstacleCheck).toHaveBeenCalledWith(3, 7, 'rev-1', expect.any(AbortSignal));
});
it('UPC-006: offline with nothing cached it says so, and reads again once online', async () => {
network.offline = true;
repo.obstacleCheck.mockRejectedValueOnce(new Error('Network Error'));
const { result, rerender } = renderChecklist();
await waitFor(() => expect(result.current.status).toBe('offline-empty'));
expect(result.current).toMatchObject({ check: null, canDecide: false });
network.offline = false;
rerender(options());
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(repo.obstacleCheck).toHaveBeenCalledTimes(2);
});
it('UPC-007: a read that fails online is an error, with nothing to decide', async () => {
repo.obstacleCheck.mockRejectedValueOnce(new Error('Request failed with status code 500'));
const { result } = renderChecklist();
await waitFor(() => expect(result.current.status).toBe('error'));
expect(result.current).toMatchObject({ check: null, canDecide: false });
});
it('UPC-008: reads again only when the bench revision changes', async () => {
const { result, rerender } = await renderReady();
rerender(options({ tours: [paddleTour()] }));
rerender(options({ tours: [paddleTour({ name: 'Regen, renamed' })] }));
expect(result.current.status).toBe('ready');
expect(repo.obstacleCheck).toHaveBeenCalledTimes(1);
const next = obstacleCheck({ revision: 'rev-2' });
repo.obstacleCheck.mockResolvedValueOnce({ check: next, stale: false });
rerender(options({ tours: [paddleTour({ paddle_check: paddleBench({ revision: 'rev-2' }) })] }));
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.check).toEqual(next));
expect(repo.obstacleCheck).toHaveBeenCalledTimes(2);
expect(repo.obstacleCheck).toHaveBeenLastCalledWith(3, 7, 'rev-2', expect.any(AbortSignal));
});
it('UPC-009: an answer still on its way for the previous revision is aborted and dropped', async () => {
const late = deferred<CheckRead>();
const next = obstacleCheck({ revision: 'rev-2' });
repo.obstacleCheck.mockReturnValueOnce(late.promise).mockResolvedValueOnce({ check: next, stale: false });
const { result, rerender } = renderChecklist();
rerender(options({ tours: [paddleTour({ paddle_check: paddleBench({ revision: 'rev-2' }) })] }));
await waitFor(() => expect(result.current.check).toEqual(next));
expect(repo.obstacleCheck.mock.calls[0]?.[3]?.aborted).toBe(true);
await act(async () => {
late.resolve({ check: CHECK, stale: false });
await late.promise;
});
expect(result.current.check).toEqual(next);
});
it('UPC-010: deciding waits while the route has unsaved changes and is never offered to a viewer', async () => {
const { result, rerender } = await renderReady();
expect(result.current.canDecide).toBe(true);
rerender(options({ planner: planner({ hasUnsavedChanges: true }) }));
expect(result.current).toMatchObject({ status: 'ready', dirty: true, canDecide: false });
rerender(options({ planner: recorded(), trackPaddlePlan: trackPlan({ dirty: true }) }));
expect(result.current).toMatchObject({ status: 'ready', dirty: true, canDecide: false });
rerender(options({ canEdit: false }));
expect(result.current).toMatchObject({ status: 'ready', dirty: false, canDecide: false });
rerender(options());
expect(result.current.canDecide).toBe(true);
expect(repo.obstacleCheck).toHaveBeenCalledTimes(1);
});
it('UPC-011: a tour made offline and not on the server yet is never decided on', async () => {
const { result } = await renderReady(
options({
planner: planner({ mode: { type: 'edit-saved', placeId: -3 } }),
tours: [paddleTour({ place_id: -3 })],
})
);
expect(result.current.canDecide).toBe(false);
});
});
describe('usePaddleChecklist: deciding', () => {
it('UPC-012: a decision shows at once, then the checklist takes the server answer and the tour is handed on', async () => {
const save = deferred<TourDetailResponse>();
repo.saveObstacleReviews.mockReturnValueOnce(save.promise);
const onSaved = vi.fn<(tour: TourListItem) => void>();
const { result, rerender } = await renderReady(options({ onSaved }));
let saving: Promise<boolean> = Promise.resolve(true);
act(() => {
saving = result.current.decide(ENTRY, 'pass');
});
expect(repo.saveObstacleReviews).toHaveBeenCalledWith(
3,
7,
{ ops: [{ key: 'node/1', op: 'decide', decision: 'pass', note: null, basis: BASIS }] },
4
);
expect(result.current).toMatchObject({
status: 'ready',
savingKey: 'node/1',
canDecide: false,
check: { revision: 'pending' },
});
expect(result.current.check?.entries[0]).toMatchObject({
status: 'decided',
decision: { decision: 'pass', note: null, decided_by: 4 },
});
// The bench counts the decision before the server has it, under the revision the list knows.
expect(result.current.summary).toEqual(paddleBench({ open: 0, decided: 1 }));
expect(onSaved).not.toHaveBeenCalled();
const answered = obstacleCheck({
revision: 'rev-2',
entries: [
obstacleEntry({
status: 'decided',
decision: { decision: 'pass', note: null, decided_at: '2026-10-17T09:00:00.000Z', decided_by: 4 },
}),
],
});
const savedTour = paddleTour({ paddle_check: paddleBench({ revision: 'rev-2', open: 0, decided: 1 }) });
await act(async () => {
save.resolve({ tour: savedTour, waypoints: [], obstacle_check: answered });
await saving;
});
expect(onSaved).toHaveBeenCalledWith(savedTour);
expect(result.current.savingKey).toBeNull();
// The workspace takes the saved tour into its list; the answer is the checklist of its new bench.
repo.obstacleCheck.mockResolvedValue({ check: answered, stale: false });
rerender(options({ onSaved, tours: [savedTour] }));
expect(result.current).toMatchObject({
status: 'ready',
check: answered,
summary: savedTour.paddle_check,
canDecide: true,
});
await flush();
expect(result.current.check).toEqual(answered);
});
it('UPC-013: a failed save is undone and says so', async () => {
const save = deferred<TourDetailResponse>();
repo.saveObstacleReviews.mockReturnValueOnce(save.promise);
const onSaved = vi.fn<(tour: TourListItem) => void>();
const { result } = await renderReady(options({ onSaved }));
let saving: Promise<boolean> = Promise.resolve(true);
act(() => {
saving = result.current.decide(ENTRY, 'portage');
});
// Carrying round without a portage planned yet is still to do, so the bench still counts it open.
expect(result.current.check?.entries[0]).toMatchObject({ status: 'todo', decision: { decision: 'portage' } });
expect(result.current.summary).toEqual(paddleBench());
await act(async () => {
save.reject(new Error('Request failed with status code 409'));
await saving;
});
expect(result.current).toMatchObject({ status: 'ready', savingKey: null, canDecide: true });
expect(result.current.check).toEqual(CHECK);
expect(result.current.summary).toEqual(TOUR.paddle_check);
// The en wording of tours.paddle.marks.failed.
expect(toast.error).toHaveBeenCalledWith('Saving did not work. Please try again.');
expect(onSaved).not.toHaveBeenCalled();
});
it('UPC-014: a new choice keeps the note, a note is trimmed and a blank one is none, clear and keep carry no choice', async () => {
const noted = obstacleEntry({
status: 'decided',
decision: { decision: 'pass', note: 'Left bank', decided_at: '2026-10-10T09:00:00.000Z', decided_by: 5 },
});
const gone = earlierEntry();
const listed = obstacleCheck({ entries: [noted, gone] });
repo.obstacleCheck.mockResolvedValue({ check: listed, stale: false });
repo.saveObstacleReviews.mockResolvedValue({ tour: TOUR, waypoints: [], obstacle_check: listed });
const onSaved = vi.fn<(tour: TourListItem) => void>();
const { result } = await renderReady(options({ onSaved }));
await act(async () => {
await result.current.decide(noted, 'portage');
});
await act(async () => {
await result.current.setNote(noted, ' Scout from the left bank ');
});
await act(async () => {
await result.current.setNote(noted, ' ');
});
await act(async () => {
await result.current.clear(noted);
});
await act(async () => {
await result.current.keep(gone);
});
expect(repo.saveObstacleReviews.mock.calls.map(([, , body]) => body.ops)).toEqual([
[{ key: 'node/1', op: 'decide', decision: 'portage', note: 'Left bank', basis: BASIS }],
[{ key: 'node/1', op: 'note', decision: null, note: 'Scout from the left bank', basis: null }],
[{ key: 'node/1', op: 'note', decision: null, note: null, basis: null }],
[{ key: 'node/1', op: 'clear', decision: null, note: null, basis: null }],
[{ key: 'node/9', op: 'keep', decision: null, note: null, basis: null }],
]);
expect(onSaved).toHaveBeenCalledTimes(5);
expect(result.current).toMatchObject({ check: listed, savingKey: null });
});
it('UPC-015: nothing is saved, found or offered as next before the checklist is there', async () => {
repo.obstacleCheck.mockReturnValueOnce(new Promise<CheckRead>(() => undefined));
const { result } = renderChecklist();
await act(async () => {
await result.current.decide(ENTRY, 'pass');
});
expect(repo.saveObstacleReviews).not.toHaveBeenCalled();
expect(result.current).toMatchObject({ status: 'loading', savingKey: null });
expect(result.current.nextOf(ENTRY)).toBeNull();
expect(result.current.entryForRef('node/1')).toBeNull();
});
it('UPC-016: names the trip member who decided, and nobody for an unknown or missing id', () => {
const { result } = renderChecklist(options({ active: false }));
expect(result.current.memberName(4)).toBe('maria');
expect(result.current.memberName(5)).toBe('jonas');
expect(result.current.memberName(9)).toBeNull();
expect(result.current.memberName(null)).toBeNull();
});
});
describe('usePaddleChecklist: the map and the route', () => {
it('UPC-017: selecting an entry the map holds selects it there and moves the camera onto it', async () => {
const { result } = await renderReady();
act(() => publishMap(['node/1']));
act(() => result.current.select(ENTRY));
expect(usePaddleObstacleStore.getState().selectedRef).toBe('node/1');
expect(lastFocus()).toEqual({ seq: 1, points: WEIR_FRAME });
expect(result.current).toMatchObject({ selectedKey: 'node/1', dialogKey: null });
});
it('UPC-018: a selection on the map marks the entry that stands for the object, a lock by any of its parts', async () => {
const lock = obstacleEntry({
key: 'lock:node/5',
refs: ['node/6', 'node/5'],
kind: 'lock',
name: 'Schleuse Roding',
along_m: 2400,
});
repo.obstacleCheck.mockResolvedValue({ check: obstacleCheck({ entries: [ENTRY, lock] }), stale: false });
const { result } = await renderReady();
expect(result.current.entryForRef('node/5')).toEqual(lock);
expect(result.current.entryForRef('node/42')).toBeNull();
act(() => selectPaddleObstacle('node/5'));
expect(result.current.selectedKey).toBe('lock:node/5');
act(() => selectPaddleObstacle('node/42'));
expect(result.current.selectedKey).toBeNull();
// A click on the map only selects; the camera stays where it is.
expect(lastFocus()).toBeNull();
});
it('UPC-019: selecting an entry the map does not hold frames its area and opens its dialog', async () => {
const { result } = await renderReady();
act(() => selectPaddleObstacle('node/77'));
act(() => result.current.select(ENTRY));
expect(usePaddleObstacleStore.getState().selectedRef).toBeNull();
expect(lastFocus()).toEqual({ seq: 1, points: WEIR_FRAME });
expect(result.current).toMatchObject({ dialogKey: 'node/1', selectedKey: 'node/1' });
act(() => result.current.closeDialog());
expect(result.current).toMatchObject({ dialogKey: null, selectedKey: null });
// Once the map holds it, selecting it goes through the map and closes the dialog.
act(() => result.current.select(ENTRY));
act(() => publishMap(['node/1']));
act(() => result.current.select(ENTRY));
expect(result.current).toMatchObject({ dialogKey: null, selectedKey: 'node/1' });
expect(lastFocus()?.seq).toBe(3);
});
it('UPC-020: next selects the next entry along the route still asking for a look', async () => {
const decision = { decision: 'pass' as const, note: null, decided_at: '2026-10-10T09:00:00.000Z', decided_by: 5 };
const dam = obstacleEntry({
key: 'node/2',
refs: ['node/2'],
kind: 'dam',
along_m: 2500,
status: 'decided',
decision,
});
const rapids = obstacleEntry({
key: 'node/3',
refs: ['node/3'],
kind: 'rapids',
along_m: 4000,
status: 'review',
changed: ['source'],
decision,
});
repo.obstacleCheck.mockResolvedValue({ check: obstacleCheck({ entries: [ENTRY, dam, rapids] }), stale: false });
const { result } = await renderReady();
expect(result.current.nextOf(ENTRY)).toEqual(rapids);
act(() => result.current.next(ENTRY));
expect(result.current).toMatchObject({ dialogKey: 'node/3', selectedKey: 'node/3' });
// After the last one there is none, and nothing moves.
expect(result.current.nextOf(rapids)).toBeNull();
act(() => result.current.next(rapids));
expect(result.current.dialogKey).toBe('node/3');
expect(lastFocus()?.seq).toBe(1);
});
it('UPC-021: planning a portage on a recorded track starts the portage pick of the track, once', async () => {
const plan = trackPlan();
const { result, rerender } = await renderReady(options({ planner: recorded(), trackPaddlePlan: plan }));
act(() => publishMap(['node/1']));
act(() => result.current.select(ENTRY));
act(() => result.current.planPortage(ENTRY));
expect(plan.startPick).toHaveBeenCalledWith('portage');
expect(usePaddleObstacleStore.getState().selectedRef).toBeNull();
expect(lastFocus()).toEqual({ seq: 2, points: WEIR_FRAME });
expect(result.current.dialogKey).toBeNull();
expect(setSelectedWaypointId).not.toHaveBeenCalled();
// While the pick is on, a second press leaves it on rather than switching it off.
const picking = trackPlan({ pick: 'portage' });
rerender(options({ planner: recorded(), trackPaddlePlan: picking }));
act(() => result.current.planPortage(ENTRY));
expect(picking.startPick).not.toHaveBeenCalled();
});
it('UPC-022: planning a portage on a planned tour selects the control point whose leg passes the obstacle', async () => {
const plan = trackPlan();
const { result, rerender } = await renderReady(options({ trackPaddlePlan: plan }));
act(() => result.current.select(ENTRY));
expect(result.current.dialogKey).toBe('node/1');
act(() => result.current.planPortage(ENTRY));
expect(setSelectedWaypointId).toHaveBeenLastCalledWith('w2');
expect(result.current.dialogKey).toBeNull();
expect(lastFocus()).toEqual({ seq: 2, points: WEIR_FRAME });
expect(plan.startPick).not.toHaveBeenCalled();
act(() => result.current.planPortage(obstacleEntry({ along_m: 500 })));
expect(setSelectedWaypointId).toHaveBeenLastCalledWith('w1');
// Past the last control point the portage starts on the last leg.
act(() => result.current.planPortage(obstacleEntry({ along_m: 9000 })));
expect(setSelectedWaypointId).toHaveBeenLastCalledWith('w2');
// A route of one point has no leg to carry from.
rerender(options({ planner: planner({ waypoints: WAYPOINTS.slice(0, 1) }), trackPaddlePlan: plan }));
act(() => result.current.planPortage(ENTRY));
expect(setSelectedWaypointId).toHaveBeenCalledTimes(3);
});
it('UPC-023: selecting an own portage frames it, and on a planned tour selects its control point', async () => {
const { result, rerender } = await renderReady();
act(() => result.current.selectOwn(ownPortage()));
expect(lastFocus()).toEqual({
seq: 1,
points: [
[49.1985, 12.003],
[49.2016, 12.0073],
],
});
expect(setSelectedWaypointId).toHaveBeenCalledWith('w2');
// A portage laid onto a recorded track belongs to no control point.
rerender(options({ planner: recorded() }));
act(() => result.current.selectOwn(ownPortage({ index: null })));
expect(lastFocus()?.seq).toBe(2);
expect(setSelectedWaypointId).toHaveBeenCalledTimes(1);
});
});
describe('usePaddleChecklist after review', () => {
const answer = (check: typeof CHECK, revision: string, place = 7): TourDetailResponse => ({
tour: paddleTour({ place_id: place, paddle_check: paddleBench({ revision }) }),
waypoints: [],
obstacle_check: check,
});
it('UPC-024: a save answered after another tour opened leaves that tour as it read itself', async () => {
const other = paddleTour({ place_id: 8, paddle_check: paddleBench({ revision: 'rev-8' }) });
const save = deferred<TourDetailResponse>();
repo.saveObstacleReviews.mockReturnValueOnce(save.promise);
const props = options({ tours: [TOUR, other] });
const { result, rerender } = await renderReady(props);
act(() => {
void result.current.decide(ENTRY, 'pass');
});
const otherCheck = obstacleCheck({
revision: 'rev-8',
entries: [obstacleEntry({ key: 'node/8', refs: ['node/8'] })],
});
repo.obstacleCheck.mockResolvedValue({ check: otherCheck, stale: false });
rerender({ ...props, planner: planner({ mode: { type: 'edit-saved', placeId: 8 } }) });
await waitFor(() => expect(result.current.check?.entries[0]?.key).toBe('node/8'));
expect(result.current.savingKey).toBeNull();
await act(async () => {
save.resolve(
answer(obstacleCheck({ revision: 'rev-2', entries: [obstacleEntry({ status: 'decided' })] }), 'rev-2')
);
await save.promise;
});
expect(result.current.status).toBe('ready');
expect(result.current.check?.entries[0]?.key).toBe('node/8');
expect(props.onSaved).toHaveBeenCalledWith(expect.objectContaining({ place_id: 7 }));
});
it('UPC-025: a save whose checklist is of another revision than its bench stays marked stale', async () => {
const { result, rerender } = await renderReady();
repo.saveObstacleReviews.mockResolvedValueOnce(answer(obstacleCheck({ revision: 'rev-1' }), 'local:u1'));
await act(async () => {
await result.current.decide(ENTRY, 'pass');
});
rerender(options({ tours: [paddleTour({ paddle_check: paddleBench({ revision: 'local:u1' }) })] }));
expect(result.current.stale).toBe(true);
});
it('UPC-026: a read that finds a newer bench on the server hands the tour on and is not stale', async () => {
const newer = paddleTour({ paddle_check: paddleBench({ revision: 'rev-9' }) });
repo.obstacleCheck.mockResolvedValue({ check: obstacleCheck({ revision: 'rev-9' }), stale: true, tour: newer });
const props = options({ tours: [paddleTour({ paddle_check: paddleBench({ revision: 'local:u1' }) })] });
const { result, rerender } = renderChecklist(props);
await waitFor(() => expect(props.onSaved).toHaveBeenCalledWith(newer));
rerender({ ...props, tours: [newer] });
expect(result.current).toMatchObject({ status: 'ready', stale: false });
});
it('UPC-027: while the same tour reads again its last checklist stays, locked', async () => {
const { result, rerender } = await renderReady();
const again = deferred<CheckRead>();
repo.obstacleCheck.mockReturnValueOnce(again.promise);
rerender(options({ tours: [paddleTour({ paddle_check: paddleBench({ revision: 'rev-3' }) })] }));
expect(result.current.status).toBe('loading');
expect(result.current.check).toEqual(CHECK);
expect(result.current.canDecide).toBe(false);
await act(async () => {
again.resolve({ check: obstacleCheck({ revision: 'rev-3' }), stale: false });
await again.promise;
});
expect(result.current.status).toBe('ready');
});
it('UPC-028: a saved tour turned into a paddle tour and not saved yet asks to be saved first', () => {
const hike = paddleTour({ tour_type: 'hike', boat_type: null, water_context: null, paddle_check: null });
const { result } = renderChecklist(options({ tours: [hike], planner: planner({ isPaddle: true }) }));
expect(result.current).toMatchObject({ status: 'new', tour: null, canDecide: false });
expect(repo.obstacleCheck).not.toHaveBeenCalled();
});
it('UPC-029: planning a portage is offered to editors only', async () => {
expect((await renderReady()).result.current.canPlanPortage).toBe(true);
expect((await renderReady(options({ canEdit: false }))).result.current.canPlanPortage).toBe(false);
});
it('UPC-030: a note whose save failed answers false, one that went through true', async () => {
const { result } = await renderReady();
repo.saveObstacleReviews.mockRejectedValueOnce(new Error('offline'));
let saved: boolean | undefined;
await act(async () => {
saved = await result.current.setNote(ENTRY, 'Links');
});
expect(saved).toBe(false);
repo.saveObstacleReviews.mockResolvedValueOnce(answer(CHECK, 'rev-1'));
await act(async () => {
saved = await result.current.setNote(ENTRY, 'Links');
});
expect(saved).toBe(true);
});
it('UPC-031: a save on one tour and then on another keep their own pending state until each is answered', async () => {
const other = paddleTour({ place_id: 8, paddle_check: paddleBench({ revision: 'rev-8' }) });
const otherEntry = obstacleEntry({ key: 'node/8', refs: ['node/8'] });
const first = deferred<TourDetailResponse>();
const second = deferred<TourDetailResponse>();
repo.saveObstacleReviews.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const props = options({ tours: [TOUR, other] });
const { result, rerender } = await renderReady(props);
act(() => {
void result.current.decide(ENTRY, 'pass');
});
repo.obstacleCheck.mockResolvedValue({
check: obstacleCheck({ revision: 'rev-8', entries: [otherEntry] }),
stale: false,
});
rerender({ ...props, planner: planner({ mode: { type: 'edit-saved', placeId: 8 } }) });
await waitFor(() => expect(result.current.check?.entries[0]?.key).toBe('node/8'));
act(() => {
void result.current.decide(otherEntry, 'portage');
});
expect(result.current.savingKey).toBe('node/8');
await act(async () => {
first.resolve(answer(obstacleCheck({ revision: 'rev-2' }), 'rev-2'));
await first.promise;
});
expect(result.current.savingKey).toBe('node/8');
expect(result.current.check?.entries[0]).toMatchObject({ key: 'node/8', decision: { decision: 'portage' } });
});
it('UPC-032: a saved paddle tour the list has not loaded yet is off until it is there, then read', async () => {
const { result, rerender } = renderChecklist(options({ tours: [] }));
expect(result.current.status).toBe('off');
expect(repo.obstacleCheck).not.toHaveBeenCalled();
rerender(options());
await waitFor(() => expect(result.current.status).toBe('ready'));
});
});
@@ -0,0 +1,365 @@
import {
haversineMetres,
paddleCheckSummaryOf,
type ObstacleDecision,
type ObstacleReviewOp,
type PaddleCheck,
type TourListItem,
type TourObstacleCheck,
type TourObstacleEntry,
type TourOwnEntry,
type TripMember,
} from '@trek/shared';
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { useTranslation } from '../../../i18n';
import { applyObstacleOps } from '../../../repo/obstacleCheckLocal';
import { tourRepo } from '../../../repo/tourRepo';
import { useAuthStore } from '../../../store/authStore';
import {
focusPaddleArea,
focusPaddleObstacle,
selectPaddleObstacle,
usePaddleObstacleStore,
} from '../../../store/paddleObstacleStore';
import { useToast } from '../../shared/Toast';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { focusPointsOf, nextPendingEntry } from './paddleCheckModel';
import type { TrackPaddlePlan } from './useTrackPaddlePlan';
export type PaddleChecklistStatus = 'off' | 'new' | 'loading' | 'ready' | 'offline-empty' | 'error';
interface PaddleChecklistOptions {
tripId: number;
planner: TourPlannerController;
tours: readonly TourListItem[];
tripMembers: readonly TripMember[];
canEdit: boolean;
/** The tours workspace is open on a desktop with the Tours addon on. */
active: boolean;
trackPaddlePlan?: Pick<TrackPaddlePlan, 'dirty' | 'startPick' | 'pick'>;
/** The tour as it stands after a save, for the lists and the map to take it in. */
onSaved: (tour: TourListItem) => void;
}
interface Loaded {
placeId: number;
key: string;
check: TourObstacleCheck | null;
stale: boolean;
failed: boolean;
}
/** The paddle tour the planner has open, saved or shown as recorded, or null. */
function openPaddleTour(mode: TourPlannerController['mode'], tours: readonly TourListItem[]): TourListItem | null {
if (mode.type !== 'edit-saved' && mode.type !== 'view-gpx') return null;
const tour = tours.find((item) => item.place_id === mode.placeId) ?? (mode.type === 'view-gpx' ? mode.tour : null);
return tour?.tour_type === 'kayak' ? tour : null;
}
/** Reads the checklist for the bench the list shows, again whenever its revision or the connection changes. */
/** The read key of a tour's checklist: a new bench revision or a change of the connection reads it again. */
const readKey = (placeId: number | null, revision: string | null, offline: boolean) =>
`${placeId}:${revision}:${offline}`;
/**
* Reads the checklist for the bench the list shows, again whenever its
* revision or the connection changes. A read that finds the server further
* than the list (a queued write replayed meanwhile) hands the newer tour on,
* so the list and its bench catch up.
*/
function useChecklistRead(
tripId: number,
tour: TourListItem | null,
active: boolean,
onNewer: (tour: TourListItem) => void
) {
const { offline } = useNetworkMode();
const placeId = tour?.place_id ?? null;
const revision = tour?.paddle_check?.revision ?? null;
const key = readKey(placeId, revision, offline);
const [loaded, setLoaded] = useState<Loaded | null>(null);
// Handing the newer tour on is no reason to read again.
const takeNewer = useEffectEvent(onNewer);
useEffect(() => {
if (!active || placeId === null) return;
const controller = new AbortController();
tourRepo
.obstacleCheck(tripId, placeId, revision, controller.signal)
.then(({ check, stale, tour: answered }) => {
if (controller.signal.aborted) return;
const newer = answered?.paddle_check?.revision;
if (answered && newer && newer !== revision) {
setLoaded({
placeId,
key: readKey(placeId, newer, offline),
check,
stale: check?.revision !== newer,
failed: false,
});
takeNewer(answered);
return;
}
setLoaded({ placeId, key, check, stale, failed: false });
})
.catch(() => {
if (!controller.signal.aborted) setLoaded({ placeId, key, check: null, stale: false, failed: true });
});
return () => controller.abort();
}, [active, key, offline, placeId, revision, tripId]);
const current = loaded?.key === key ? loaded : null;
// While the same tour reads again, what it read before stays in view, locked, so a panel open on it stays open.
const previous = !current && loaded?.placeId === placeId && loaded.check ? loaded : null;
return { current, previous, offline, key, setLoaded };
}
/** Where a portage for an obstacle starts on a planned tour: the control point whose leg passes it. */
function waypointBefore(points: TourPlannerController['waypoints'], alongM: number): string | null {
let distance = 0;
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
distance += haversineMetres(a.lat, a.lng, b.lat, b.lng);
if (distance >= alongM) return a.id;
}
return points[points.length - 2]?.id ?? null;
}
/**
* A new paddle draft, or a saved tour the list shows as another type that the
* planner turned into a paddle tour: no server check exists for either. A
* tour the list does not hold yet (still loading) is neither.
*/
function unsavedPaddleDraft(planner: TourPlannerController, tours: readonly TourListItem[]): boolean {
if (!planner.isPaddle) return false;
const { mode } = planner;
if (mode.type === 'new-draft') return true;
return (
mode.type === 'edit-saved' && tours.some((item) => item.place_id === mode.placeId && item.tour_type !== 'kayak')
);
}
/** Where the checklist of the open tour stands. */
function checklistStatus(input: {
active: boolean;
newPaddleDraft: boolean;
tour: TourListItem | null;
current: Loaded | null;
offline: boolean;
}): PaddleChecklistStatus {
if (!input.active) return 'off';
if (input.newPaddleDraft) return 'new';
if (!input.tour) return 'off';
if (!input.current) return 'loading';
if (input.current.check) return 'ready';
return input.offline ? 'offline-empty' : 'error';
}
/** The bench of the tour, recounted from a change shown before the server answered it. */
function benchOf(tour: TourListItem | null, optimistic: TourObstacleCheck | null): PaddleCheck | null {
const bench = tour?.paddle_check ?? null;
if (!bench || !optimistic) return bench;
return { ...paddleCheckSummaryOf(optimistic.entries, optimistic.own, bench), revision: bench.revision };
}
/**
* Moving between the entries: a click in the rail focuses the map on the
* object, or opens it in a dialog when the map does not hold it; an own
* portage selects its control point; planning a portage starts where the
* route can take one.
*/
function useChecklistNavigation(
planner: TourPlannerController,
check: TourObstacleCheck | null,
trackPaddlePlan: PaddleChecklistOptions['trackPaddlePlan']
) {
const { mode, waypoints, setSelectedWaypointId } = planner;
const selectedRef = usePaddleObstacleStore((state) => state.selectedRef);
const [dialogKey, setDialogKey] = useState<string | null>(null);
const entryForRef = useCallback(
(ref: string) => check?.entries.find((entry) => entry.refs.includes(ref)) ?? null,
[check]
);
const select = useCallback((entry: TourObstacleEntry) => {
const ref = entry.refs[0];
if (ref && focusPaddleObstacle(ref)) {
setDialogKey(null);
return;
}
selectPaddleObstacle(null);
focusPaddleArea(focusPointsOf(entry));
setDialogKey(entry.key);
}, []);
const selectOwn = useCallback(
(own: TourOwnEntry) => {
focusPaddleArea(focusPointsOf(own));
const waypoint = own.index === null ? undefined : waypoints[own.index];
if (mode.type === 'edit-saved' && waypoint) setSelectedWaypointId(waypoint.id);
},
[mode.type, setSelectedWaypointId, waypoints]
);
// A track takes the portage on its own pick; a planned tour selects the control point to carry from.
const planPortage = useCallback(
(entry: TourObstacleEntry) => {
selectPaddleObstacle(null);
setDialogKey(null);
focusPaddleArea(focusPointsOf(entry));
if (mode.type === 'view-gpx') {
if (trackPaddlePlan?.pick !== 'portage') trackPaddlePlan?.startPick('portage');
return;
}
const waypoint = waypointBefore(waypoints, entry.along_m ?? 0);
if (waypoint) setSelectedWaypointId(waypoint);
},
[mode.type, setSelectedWaypointId, trackPaddlePlan, waypoints]
);
const nextOf = useCallback((entry: TourObstacleEntry) => (check ? nextPendingEntry(check, entry) : null), [check]);
const selectedEntry = selectedRef ? entryForRef(selectedRef) : null;
return {
selectedKey: selectedEntry?.key ?? dialogKey,
dialogKey,
entryForRef,
select,
selectOwn,
closeDialog: () => setDialogKey(null),
nextOf,
next: (entry: TourObstacleEntry) => {
const following = nextOf(entry);
if (following) select(following);
},
planPortage,
};
}
/**
* PAD-06 and PAD-09 in the tours workspace: the checklist of the open paddle
* tour, read through the offline core for the bench the list shows, with the
* paddlers' decisions saved optimistically and rolled back on a failure.
* Deciding waits while the route has unsaved changes, since a decision binds
* to the route the server judged.
*/
export function usePaddleChecklist(options: PaddleChecklistOptions) {
const { tripId, planner, tours, canEdit, active, trackPaddlePlan } = options;
const tour = useMemo(() => openPaddleTour(planner.mode, tours), [planner.mode, tours]);
const read = useChecklistRead(tripId, tour, active, options.onSaved);
const writes = useChecklistWrites(options, tour, read);
const check = writes.optimistic ?? read.current?.check ?? read.previous?.check ?? null;
const navigation = useChecklistNavigation(planner, check, trackPaddlePlan);
const dirty = planner.hasUnsavedChanges || !!trackPaddlePlan?.dirty;
const status = checklistStatus({
active,
newPaddleDraft: unsavedPaddleDraft(planner, tours),
tour,
current: read.current,
offline: read.offline,
});
const saved = (tour?.place_id ?? -1) > 0;
return {
status,
tour,
check,
summary: benchOf(tour, writes.optimistic),
stale: !!read.current?.stale,
dirty,
canDecide: canEdit && status === 'ready' && !dirty && saved && writes.savingKey === null,
/** Planning a portage edits the tour, so only someone who may edit it is offered it. */
canPlanPortage: canEdit,
savingKey: writes.savingKey,
...navigation,
...writes.actions,
memberName: (id: number | null) => options.tripMembers.find((member) => member.id === id)?.username ?? null,
};
}
export type PaddleChecklist = ReturnType<typeof usePaddleChecklist>;
interface PendingSave {
key: string;
check: TourObstacleCheck;
}
/** The saves on their way without this one, once it is answered; a later save of the same tour stays. */
function withoutSave(
all: Readonly<Record<number, PendingSave>>,
placeId: number,
done: PendingSave
): Readonly<Record<number, PendingSave>> {
if (all[placeId] !== done) return all;
const { [placeId]: _done, ...rest } = all;
return rest;
}
/** The decisions saved through the offline core: shown at once, the server's answer taken in, a failure undone. */
function useChecklistWrites(
options: PaddleChecklistOptions,
tour: TourListItem | null,
read: ReturnType<typeof useChecklistRead>
) {
const { tripId, onSaved } = options;
const { t } = useTranslation();
const toast = useToast();
const userId = useAuthStore((state) => state.user?.id ?? null);
// The save on its way per tour, so one tour's answer never clears another's.
const [pending, setPending] = useState<Readonly<Record<number, PendingSave>>>({});
const base = read.current?.check ?? null;
const placeId = tour?.place_id ?? null;
const { setLoaded, offline } = read;
const save = useCallback(
async (key: string, op: ObstacleReviewOp) => {
if (!base || placeId === null) return false;
const entry: PendingSave = {
key,
check: applyObstacleOps(base, [op], { now: new Date().toISOString(), userId, revision: 'pending' }),
};
setPending((all) => ({ ...all, [placeId]: entry }));
try {
const result = await tourRepo.saveObstacleReviews(tripId, placeId, { ops: [op] }, userId);
const revision = result.tour.paddle_check?.revision ?? null;
const check = result.obstacle_check ?? base;
const next: Loaded = {
placeId,
key: readKey(placeId, revision, offline),
check,
// A checklist still judged on an older route than the bench stays marked as such.
stale: revision !== null && check.revision !== revision,
failed: false,
};
// Another tour opened meanwhile keeps what it read; this one's answer reaches the list and the cache.
setLoaded((current) => (current && !current.key.startsWith(`${placeId}:`) ? current : next));
onSaved(result.tour);
return true;
} catch {
toast.error(t('tours.paddle.marks.failed'));
return false;
} finally {
setPending((all) => withoutSave(all, placeId, entry));
}
},
[base, offline, onSaved, placeId, setLoaded, t, toast, tripId, userId]
);
const actions = useMemo(
() => ({
decide: (entry: TourObstacleEntry, decision: ObstacleDecision) =>
save(entry.key, {
key: entry.key,
op: 'decide',
decision,
note: entry.decision?.note ?? null,
basis: entry.basis,
}),
setNote: (entry: TourObstacleEntry, note: string | null) =>
save(entry.key, { key: entry.key, op: 'note', decision: null, note: note?.trim() || null, basis: null }),
clear: (entry: TourObstacleEntry) =>
save(entry.key, { key: entry.key, op: 'clear', decision: null, note: null, basis: null }),
keep: (entry: TourObstacleEntry) =>
save(entry.key, { key: entry.key, op: 'keep', decision: null, note: null, basis: null }),
}),
[save]
);
// A save still running for a tour left meanwhile shows nothing on the one open now.
const mine = placeId === null ? null : (pending[placeId] ?? null);
return { optimistic: mine?.check ?? null, savingKey: mine?.key ?? null, actions };
}
@@ -0,0 +1,517 @@
/**
* FE-PADDLE-GL-001 to FE-PADDLE-GL-017: the paddle objects on a MapLibre or
* Mapbox GL map, and the hit test the map's own click handler asks first.
*
* Pinned: one source and seven layers bottom to top, the ones only in the area
* from PADDLE_AREA_MIN_ZOOM on; each object as a line and a marker that say
* how it stands to the route; a pick or new objects replace the source's data
* in place, and a styledata never hands the same data over twice; a style
* still loading, or one a basemap change rebuilt, takes the layers on its next
* styledata; one map-level click picks the top hit's object; the pointer turns
* into a hand over the layers a click can land on; the plain and the picked
* look of every marker are drawn once, never after the layer went; without
* objects the layers and the source go; a map without the methods is no hit.
*/
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import type { PaddleFeature, PaddleGeometry, PaddleObstacleKind, RelatedObstacle } from '@trek/shared';
import {
PADDLE_AREA_MIN_ZOOM,
paddleMapItems,
paddleMarkerImageId,
paddleMarkerSvg,
type PaddleMapItem,
type PaddleMapLayer,
} from './paddleMapLayer';
import type { PaddlePaint } from './paddlePaint';
import { isPaddleHit, usePaddleLayerGL, type PaddleGlMap } from './usePaddleLayerGL';
/** Test colours standing in for the theme tokens (styles/paddle.css), distinct so each can be told apart. */
const PAINT: PaddlePaint = {
obstacle: '#334155',
access: '#4338ca',
landing: '#0f766e',
portage: '#9a6233',
disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)',
};
const SOURCE = 'trek-paddle';
const CASING = 'trek-paddle-casing';
const CASING_AREA = 'trek-paddle-casing-area';
const LINE = 'trek-paddle-line';
const LINE_NEAR = 'trek-paddle-line-near';
const LINE_AREA = 'trek-paddle-line-area';
const PINS = 'trek-paddle-pins';
const PINS_AREA = 'trek-paddle-pins-area';
/** Bottom to top, as the hook adds them. */
const LAYERS = [CASING_AREA, CASING, LINE_AREA, LINE, LINE_NEAR, PINS_AREA, PINS];
/** The layers a click can land on, the markers first. */
const HIT_LAYERS = [PINS, PINS_AREA, LINE, LINE_NEAR, LINE_AREA];
function feature(ref: string, kind: PaddleObstacleKind, geometry: PaddleGeometry): PaddleFeature {
return { ref, kind, group_key: null, name: null, geometry, tags: {}, tag_hash: 'h', region_id: 'r1' };
}
const LOCK_LINE: [number, number][] = [
[11.1, 48.1],
[11.11, 48.11],
];
const WEIR_LINE: [number, number][] = [
[11.2, 48.2],
[11.21, 48.21],
[11.22, 48.22],
];
const PORTAGE_LINE: [number, number][] = [
[11.3, 48.3],
[11.31, 48.31],
];
const LOCK = feature('way/1', 'lock', { type: 'LineString', coordinates: LOCK_LINE });
const WEIR = feature('way/2', 'weir', { type: 'LineString', coordinates: WEIR_LINE });
const PORTAGE = feature('way/3', 'portage', { type: 'LineString', coordinates: PORTAGE_LINE });
const PUT_IN = feature('node/4', 'put_in', { type: 'Point', coordinates: [11.4, 48.4] });
const SLIPWAY = feature('node/5', 'slipway', { type: 'Point', coordinates: [11.5, 48.5] });
const RELATED: RelatedObstacle[] = [
{ feature: LOCK, relation: 'on_route', distanceM: 0, alongM: 1200 },
{ feature: WEIR, relation: 'near', distanceM: 90, alongM: 3400 },
];
/** A lock on the route, a weir near it, three objects only in the area; the put-in and the slipway look alike. */
const ITEMS = paddleMapItems([LOCK, WEIR, PORTAGE, PUT_IN, SLIPWAY], RELATED, PAINT);
function itemOf(ref: string): PaddleMapItem {
const item = ITEMS.find((entry) => entry.feature.ref === ref);
if (!item) throw new Error(`no paddle item ${ref}`);
return item;
}
function layerOf(items: PaddleMapItem[], selectedRef: string | null = null, onSelect = vi.fn()): PaddleMapLayer {
return { items, selectedRef, selectedKey: null, onSelect };
}
const looksOf = (item: PaddleMapItem) => [paddleMarkerImageId(item, false), paddleMarkerImageId(item, true)];
const svgUrl = (item: PaddleMapItem, selected: boolean) =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(paddleMarkerSvg(item, selected))}`;
const drawn: FakeImage[] = [];
/** jsdom never decodes an image, so these load when a test says so. */
class FakeImage {
onload: (() => void) | null = null;
src = '';
readonly width: number;
constructor(width: number) {
this.width = width;
drawn.push(this);
}
}
const loadImages = () => drawn.forEach((image) => image.onload?.());
type Handler = (event?: unknown) => void;
interface Hit {
properties: Record<string, unknown> | null;
}
interface LayerSpec {
id: string;
type: string;
source: string;
minzoom?: number;
paint?: Record<string, unknown>;
}
interface FakeFeature {
geometry: unknown;
properties: Record<string, unknown> | null;
}
interface FakeData {
features: FakeFeature[];
}
interface FakeSource {
data: FakeData;
setData: Mock<(next: FakeData) => void>;
}
function fakeGlMap() {
const sources = new Map<string, FakeSource>();
const layers = new Set<string>();
const images = new Set<string>();
const handlers = new Map<string, Set<Handler>>();
const state = { loading: false, removed: false, hits: [] as Hit[] };
const canvas = document.createElement('canvas');
const keyOf = (type: string, layer: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type);
const map = {
getSource: vi.fn((id: string) => sources.get(id)),
addSource: vi.fn((id: string, spec: { type: string; data: FakeData }) => {
// Like a style still loading: the map takes no source yet.
if (state.loading) throw new Error('Style is not done loading.');
const source: FakeSource = {
data: spec.data,
setData: vi.fn((next: FakeData) => {
source.data = next;
}),
};
sources.set(id, source);
}),
removeSource: vi.fn((id: string) => {
sources.delete(id);
}),
// Like MapLibre once its view went away: asking it for a layer throws.
getLayer: vi.fn((id: string) => {
if (state.removed) throw new TypeError('this.style is undefined');
return layers.has(id) ? { id } : undefined;
}),
addLayer: vi.fn((spec: LayerSpec) => {
layers.add(spec.id);
}),
removeLayer: vi.fn((id: string) => {
layers.delete(id);
}),
hasImage: vi.fn((id: string) => images.has(id)),
addImage: vi.fn((id: string) => {
images.add(id);
}),
queryRenderedFeatures: vi.fn(() => state.hits),
getCanvas: vi.fn(() => canvas),
on: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
const key = keyOf(type, layerOrFn);
const set = handlers.get(key) ?? new Set<Handler>();
set.add((fn ?? layerOrFn) as Handler);
handlers.set(key, set);
}),
off: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
handlers.get(keyOf(type, layerOrFn))?.delete((fn ?? layerOrFn) as Handler);
}),
};
return {
map: map as unknown as PaddleGlMap,
spies: map,
state,
sources,
layers,
images,
canvas,
emit: (key: string, event?: unknown) => [...(handlers.get(key) ?? [])].forEach((fn) => fn(event)),
listeners: () => [...handlers.values()].reduce((sum, set) => sum + set.size, 0),
/** A basemap change: the rebuilt style has none of the paddle source, layers or images. */
dropStyle: () => {
sources.clear();
layers.clear();
images.clear();
},
};
}
type FakeGl = ReturnType<typeof fakeGlMap>;
const point = { x: 4, y: 4 };
const addedIds = (gl: FakeGl) => gl.spies.addLayer.mock.calls.map(([spec]) => spec.id);
const specOf = (gl: FakeGl, id: string) => gl.spies.addLayer.mock.calls.find(([spec]) => spec.id === id)?.[0];
const dataOf = (gl: FakeGl) => gl.sources.get(SOURCE)?.data.features ?? [];
function propsOf(gl: FakeGl, ref: string) {
const features = dataOf(gl).filter((entry) => entry.properties?.ref === ref);
return features.map((entry) => entry.properties);
}
function renderLayer(gl: FakeGl, layer: PaddleMapLayer | undefined) {
return renderHook((props: { layer: PaddleMapLayer | undefined }) => usePaddleLayerGL(gl.map, props.layer), {
initialProps: { layer },
});
}
beforeEach(() => {
drawn.length = 0;
vi.stubGlobal('Image', FakeImage);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('usePaddleLayerGL', () => {
it('FE-PADDLE-GL-001: adds one source and seven layers bottom to top, the area ones from PADDLE_AREA_MIN_ZOOM on', () => {
const gl = fakeGlMap();
renderLayer(gl, layerOf(ITEMS));
expect(gl.spies.addSource).toHaveBeenCalledTimes(1);
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }));
expect(addedIds(gl)).toEqual(LAYERS);
expect(gl.spies.addLayer.mock.calls.every(([spec]) => spec.source === SOURCE)).toBe(true);
const zoomed = gl.spies.addLayer.mock.calls.filter(([spec]) => spec.minzoom === PADDLE_AREA_MIN_ZOOM);
expect(zoomed.map(([spec]) => spec.id)).toEqual([CASING_AREA, LINE_AREA, PINS_AREA]);
expect(specOf(gl, PINS)?.type).toBe('symbol');
expect(specOf(gl, PINS_AREA)?.type).toBe('symbol');
expect(specOf(gl, LINE_NEAR)?.paint?.['line-dasharray']).toEqual([2, 1.6]);
expect(specOf(gl, LINE)?.paint?.['line-dasharray']).toBeUndefined();
});
it('FE-PADDLE-GL-002: draws each object as a line and a marker that say how it stands to the route', () => {
const gl = fakeGlMap();
renderLayer(gl, layerOf(ITEMS));
const lock = itemOf('way/1');
const weir = itemOf('way/2');
const portage = itemOf('way/3');
expect(dataOf(gl).map((entry) => entry.properties)).toEqual([
{ ref: 'way/1', color: lock.color, width: 4, opacity: 1, near: false, casing: PAINT.disc, area: false },
{ ref: 'way/1', icon: paddleMarkerImageId(lock, false), order: 2, area: false },
{ ref: 'way/2', color: weir.color, width: 3, opacity: 0.85, near: true, casing: PAINT.disc, area: false },
{ ref: 'way/2', icon: paddleMarkerImageId(weir, false), order: 1, area: false },
{ ref: 'way/3', color: portage.color, width: 2, opacity: 0.5, near: false, casing: PAINT.disc, area: true },
{ ref: 'way/3', icon: paddleMarkerImageId(portage, false), order: 0, area: true },
{ ref: 'node/4', icon: paddleMarkerImageId(itemOf('node/4'), false), order: 0, area: true },
{ ref: 'node/5', icon: paddleMarkerImageId(itemOf('node/5'), false), order: 0, area: true },
]);
// GeoJSON order, [lng, lat], as the features came in; a line's marker sits in its middle.
expect(dataOf(gl).map((entry) => entry.geometry)).toEqual([
{ type: 'LineString', coordinates: LOCK_LINE },
{ type: 'Point', coordinates: [11.11, 48.11] },
{ type: 'LineString', coordinates: WEIR_LINE },
{ type: 'Point', coordinates: [11.21, 48.21] },
{ type: 'LineString', coordinates: PORTAGE_LINE },
{ type: 'Point', coordinates: [11.31, 48.31] },
{ type: 'Point', coordinates: [11.4, 48.4] },
{ type: 'Point', coordinates: [11.5, 48.5] },
]);
});
it('FE-PADDLE-GL-003: a pick hands the source new data in place, the picked object solid, on top and out of the area', () => {
const gl = fakeGlMap();
const { rerender } = renderLayer(gl, layerOf(ITEMS));
const weir = itemOf('way/2');
rerender({ layer: layerOf(ITEMS, 'way/2') });
expect(gl.sources.get(SOURCE)?.setData).toHaveBeenCalledTimes(1);
expect(propsOf(gl, 'way/2')).toEqual([
{ ref: 'way/2', color: weir.color, width: 3, opacity: 0.85, near: false, casing: PAINT.disc, area: false },
{ ref: 'way/2', icon: paddleMarkerImageId(weir, true), order: 3, area: false },
]);
rerender({ layer: layerOf(ITEMS, 'node/5') });
expect(propsOf(gl, 'node/5')).toEqual([
{ ref: 'node/5', icon: paddleMarkerImageId(itemOf('node/5'), true), order: 3, area: false },
]);
expect(gl.spies.addSource).toHaveBeenCalledTimes(1);
expect(addedIds(gl)).toEqual(LAYERS);
});
it('FE-PADDLE-GL-004: new objects replace the source data without adding the layers again, sent once per change', () => {
const gl = fakeGlMap();
const { rerender } = renderLayer(gl, layerOf(ITEMS));
// Every image the map takes fires styledata; the data it already has is not handed over again.
gl.emit('styledata');
expect(gl.sources.get(SOURCE)?.setData).not.toHaveBeenCalled();
expect(drawn).toHaveLength(10);
rerender({ layer: layerOf(ITEMS.slice(0, 1)) });
const setData = gl.sources.get(SOURCE)?.setData;
expect(setData).toHaveBeenCalledTimes(1);
expect(dataOf(gl).map((entry) => entry.properties?.ref)).toEqual(['way/1', 'way/1']);
gl.emit('styledata');
gl.emit('styledata');
expect(setData).toHaveBeenCalledTimes(1);
expect(gl.spies.addSource).toHaveBeenCalledTimes(1);
expect(addedIds(gl)).toEqual(LAYERS);
});
it('FE-PADDLE-GL-005: a basemap change that dropped the style brings the source, the layers and the images back', () => {
const gl = fakeGlMap();
const { rerender } = renderLayer(gl, layerOf(ITEMS));
rerender({ layer: layerOf(ITEMS, 'way/2') });
loadImages();
const before = drawn.length;
gl.dropStyle();
gl.emit('styledata');
expect(gl.spies.addSource).toHaveBeenCalledTimes(2);
expect(addedIds(gl)).toEqual([...LAYERS, ...LAYERS]);
expect(propsOf(gl, 'way/2')).toContainEqual(expect.objectContaining({ order: 3, area: false }));
expect(drawn).toHaveLength(before + 10);
});
it('FE-PADDLE-GL-006: a style still loading takes the layers on its next styledata', () => {
const gl = fakeGlMap();
gl.state.loading = true;
renderLayer(gl, layerOf(ITEMS));
expect(gl.spies.addLayer).not.toHaveBeenCalled();
gl.state.loading = false;
gl.emit('styledata');
expect(gl.spies.addSource).toHaveBeenCalledTimes(2);
expect(addedIds(gl)).toEqual(LAYERS);
});
it('FE-PADDLE-GL-007: one click handler for the map picks the top hit, so a marker over a line opens only its object', () => {
const gl = fakeGlMap();
const onSelect = vi.fn();
renderLayer(gl, layerOf(ITEMS, null, onSelect));
expect(gl.spies.on.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1);
gl.state.hits = [{ properties: { ref: 'node/4' } }, { properties: { ref: 'way/3' } }];
gl.emit('click', { point });
expect(gl.spies.queryRenderedFeatures).toHaveBeenCalledWith(point, { layers: HIT_LAYERS });
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith('node/4');
});
it('FE-PADDLE-GL-008: a click on nothing, on a feature without a ref or before the layers exist picks nothing', () => {
const gl = fakeGlMap();
gl.state.loading = true;
const onSelect = vi.fn();
renderLayer(gl, layerOf(ITEMS, null, onSelect));
gl.state.hits = [{ properties: { ref: 'node/4' } }];
gl.emit('click', { point });
expect(gl.spies.queryRenderedFeatures).not.toHaveBeenCalled();
gl.state.loading = false;
gl.emit('styledata');
const misses: Hit[][] = [[], [{ properties: null }], [{ properties: { ref: 7 } }]];
for (const hits of misses) {
gl.state.hits = hits;
gl.emit('click', { point });
}
expect(gl.spies.queryRenderedFeatures).toHaveBeenCalledTimes(misses.length);
expect(onSelect).not.toHaveBeenCalled();
});
it('FE-PADDLE-GL-009: a new onSelect takes the next click without rebuilding the layers or their data', () => {
const gl = fakeGlMap();
const first = vi.fn();
const next = vi.fn();
const { rerender } = renderLayer(gl, layerOf(ITEMS, null, first));
rerender({ layer: layerOf(ITEMS, null, next) });
gl.state.hits = [{ properties: { ref: 'way/2' } }];
gl.emit('click', { point });
expect(next).toHaveBeenCalledWith('way/2');
expect(first).not.toHaveBeenCalled();
expect(gl.spies.addSource).toHaveBeenCalledTimes(1);
expect(gl.sources.get(SOURCE)?.setData).not.toHaveBeenCalled();
});
it('FE-PADDLE-GL-010: the pointer turns into a hand over every layer a click can land on, and back on leave', () => {
const gl = fakeGlMap();
renderLayer(gl, layerOf(ITEMS));
for (const id of HIT_LAYERS) {
gl.emit(`mouseenter:${id}`);
expect(gl.canvas.style.cursor).toBe('pointer');
gl.emit(`mouseleave:${id}`);
expect(gl.canvas.style.cursor).toBe('');
}
expect(gl.spies.on).not.toHaveBeenCalledWith('mouseenter', CASING, expect.any(Function));
expect(gl.spies.on).not.toHaveBeenCalledWith('mouseenter', CASING_AREA, expect.any(Function));
});
it('FE-PADDLE-GL-011: unmounting removes every listener, and without a map nothing happens', () => {
const gl = fakeGlMap();
const { unmount } = renderLayer(gl, layerOf(ITEMS));
// styledata, click, and mouseenter and mouseleave on each hit layer
expect(gl.listeners()).toBe(2 + HIT_LAYERS.length * 2);
unmount();
expect(gl.listeners()).toBe(0);
expect(() => renderHook(() => usePaddleLayerGL(null, layerOf(ITEMS)))).not.toThrow();
expect(() => renderHook(() => usePaddleLayerGL(null, undefined))).not.toThrow();
});
it('FE-PADDLE-GL-012: draws the plain and the picked look of every marker once, at twice its size for sharp pixels', () => {
const gl = fakeGlMap();
const { rerender } = renderLayer(gl, layerOf(ITEMS));
// Five objects with two looks each make ten images; the put-in and the slipway share theirs.
expect(drawn).toHaveLength(10);
loadImages();
const looks = new Set(ITEMS.flatMap(looksOf));
expect(looks.size).toBe(8);
expect(gl.images).toEqual(looks);
expect(gl.spies.addImage).toHaveBeenCalledTimes(8);
const lock = itemOf('way/1');
expect(gl.spies.addImage).toHaveBeenCalledWith(
paddleMarkerImageId(lock, false),
expect.objectContaining({ width: 60, src: svgUrl(lock, false) }),
{ pixelRatio: 2 }
);
expect(gl.spies.addImage).toHaveBeenCalledWith(
paddleMarkerImageId(lock, true),
expect.objectContaining({ width: 72, src: svgUrl(lock, true) }),
{ pixelRatio: 2 }
);
// Picking an object finds both looks on the map already.
rerender({ layer: layerOf(ITEMS, 'way/1') });
expect(drawn).toHaveLength(10);
});
it('FE-PADDLE-GL-013: an image that arrives after the layer went is not added', () => {
const gl = fakeGlMap();
const { unmount } = renderLayer(gl, layerOf(ITEMS));
unmount();
loadImages();
expect(gl.spies.addImage).not.toHaveBeenCalled();
});
it('FE-PADDLE-GL-014: without objects the layers go top first, then the source, and the next objects bring them back', () => {
const gl = fakeGlMap();
const { rerender } = renderLayer(gl, layerOf(ITEMS));
rerender({ layer: undefined });
expect(gl.spies.removeLayer.mock.calls.map(([id]) => id)).toEqual([...LAYERS].reverse());
expect(gl.spies.removeSource).toHaveBeenCalledWith(SOURCE);
expect(gl.layers.size).toBe(0);
expect(gl.sources.size).toBe(0);
rerender({ layer: layerOf(ITEMS) });
expect(gl.spies.addSource).toHaveBeenCalledTimes(2);
expect(gl.layers.size).toBe(LAYERS.length);
});
it('FE-PADDLE-GL-015: an empty list adds and removes nothing, and a map torn down meanwhile is left alone', () => {
const empty = fakeGlMap();
renderLayer(empty, layerOf([]));
expect(empty.spies.addSource).not.toHaveBeenCalled();
expect(empty.spies.removeLayer).not.toHaveBeenCalled();
expect(empty.spies.removeSource).not.toHaveBeenCalled();
const torn = fakeGlMap();
const { rerender } = renderLayer(torn, layerOf(ITEMS));
torn.state.removed = true;
expect(() => rerender({ layer: layerOf([]) })).not.toThrow();
expect(torn.spies.removeLayer).not.toHaveBeenCalled();
});
});
describe('isPaddleHit', () => {
it('FE-PADDLE-GL-016: is a hit only when a paddle layer is on the map and has a feature under the point', () => {
const gl = fakeGlMap();
expect(isPaddleHit(gl.spies, point)).toBe(false);
expect(gl.spies.queryRenderedFeatures).not.toHaveBeenCalled();
renderLayer(gl, layerOf(ITEMS));
expect(isPaddleHit(gl.spies, point)).toBe(false);
expect(gl.spies.queryRenderedFeatures).toHaveBeenCalledWith(point, { layers: HIT_LAYERS });
gl.state.hits = [{ properties: { ref: 'way/2' } }];
expect(isPaddleHit(gl.spies, point)).toBe(true);
});
it('FE-PADDLE-GL-017: a map without getLayer or queryRenderedFeatures is never hit', () => {
const query = vi.fn(() => [{}]);
expect(isPaddleHit({}, point)).toBe(false);
expect(isPaddleHit({ getLayer: () => ({}) }, point)).toBe(false);
expect(isPaddleHit({ queryRenderedFeatures: query }, point)).toBe(false);
expect(query).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,271 @@
import type { ExpressionSpecification, Map, MapMouseEvent } from 'mapbox-gl';
import { useEffect, useRef } from 'react';
import {
isAreaOnly,
isPaddleItemSelected,
markerSize,
PADDLE_AREA_MIN_ZOOM,
paddleMarkerImageId,
paddleMarkerSvg,
type PaddleMapItem,
type PaddleMapLayer,
} from './paddleMapLayer';
/** One map type for both engines, as the ferrata layer has: MapLibre answers every call made here. */
export type PaddleGlMap = Map;
const SOURCE = 'trek-paddle';
const CASING = 'trek-paddle-casing';
const CASING_AREA = 'trek-paddle-casing-area';
const LINE = 'trek-paddle-line';
const LINE_NEAR = 'trek-paddle-line-near';
const LINE_AREA = 'trek-paddle-line-area';
const PINS = 'trek-paddle-pins';
const PINS_AREA = 'trek-paddle-pins-area';
/** Bottom to top, as they are added. */
const LAYERS = [CASING_AREA, CASING, LINE_AREA, LINE, LINE_NEAR, PINS_AREA, PINS];
/** The layers a click can land on, the markers first. */
const HIT_LAYERS = [PINS, PINS_AREA, LINE, LINE_NEAR, LINE_AREA];
const WIDTH = { on_route: 4, near: 3, area: 2 } as const;
const OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const;
function collection(
items: readonly PaddleMapItem[],
selectedRef: string | null,
selectedKey: string | null
): GeoJSON.FeatureCollection {
const features: GeoJSON.Feature[] = [];
for (const item of items) {
const selected = isPaddleItemSelected(item, selectedRef, selectedKey);
const area = isAreaOnly(item, selectedRef);
if (item.line) {
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: item.line.map(([lat, lng]) => [lng, lat]) },
properties: {
ref: item.feature.ref,
color: item.color,
width: WIDTH[item.relation],
opacity: OPACITY[item.relation],
near: item.relation === 'near' && !selected,
casing: item.disc,
area,
},
});
}
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [item.marker[1], item.marker[0]] },
properties: {
ref: item.feature.ref,
icon: paddleMarkerImageId(item, selected),
order: selected ? 3 : item.relation === 'on_route' ? 2 : item.relation === 'near' ? 1 : 0,
area,
},
});
}
return { type: 'FeatureCollection', features };
}
/**
* Draws each marker look the map does not have yet, the picked look beside the
* plain one, so picking an object never shows a marker whose image is still
* loading.
*/
function addImages(map: PaddleGlMap, items: readonly PaddleMapItem[], gone: () => boolean) {
for (const item of items) {
for (const selected of [false, true]) {
const id = paddleMarkerImageId(item, selected);
if (map.hasImage(id)) continue;
const size = markerSize(item.relation, selected) * 2;
const image = new Image(size, size);
image.onload = () => {
if (!gone() && !map.hasImage(id)) map.addImage(id, image, { pixelRatio: 2 });
};
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(paddleMarkerSvg(item, selected))}`;
}
}
}
const isLine: ExpressionSpecification = ['==', ['geometry-type'], 'LineString'];
const isPoint: ExpressionSpecification = ['==', ['geometry-type'], 'Point'];
const inArea: ExpressionSpecification = ['==', ['get', 'area'], true];
const notArea: ExpressionSpecification = ['!=', ['get', 'area'], true];
const isNear: ExpressionSpecification = ['==', ['get', 'near'], true];
const notNear: ExpressionSpecification = ['!=', ['get', 'near'], true];
function addLayers(map: PaddleGlMap, data: GeoJSON.FeatureCollection): void {
map.addSource(SOURCE, { type: 'geojson', data });
const lineLayout = { 'line-cap': 'round', 'line-join': 'round' } as const;
const casingPaint = {
'line-color': ['get', 'casing'] as ExpressionSpecification,
'line-width': ['+', ['get', 'width'], 3] as ExpressionSpecification,
'line-opacity': ['get', 'opacity'] as ExpressionSpecification,
};
const linePaint = {
'line-color': ['get', 'color'] as ExpressionSpecification,
'line-width': ['get', 'width'] as ExpressionSpecification,
'line-opacity': ['get', 'opacity'] as ExpressionSpecification,
};
const pinLayout = {
'icon-image': ['get', 'icon'] as ExpressionSpecification,
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'symbol-sort-key': ['get', 'order'] as ExpressionSpecification,
};
// The objects only in the area wait for PADDLE_AREA_MIN_ZOOM; a hidden layer is also no click target.
const area = { minzoom: PADDLE_AREA_MIN_ZOOM };
const add = map.addLayer.bind(map);
add({
id: CASING_AREA,
type: 'line',
source: SOURCE,
filter: ['all', isLine, inArea],
layout: lineLayout,
paint: casingPaint,
...area,
});
add({
id: CASING,
type: 'line',
source: SOURCE,
filter: ['all', isLine, notArea],
layout: lineLayout,
paint: casingPaint,
});
add({
id: LINE_AREA,
type: 'line',
source: SOURCE,
filter: ['all', isLine, inArea],
layout: lineLayout,
paint: linePaint,
...area,
});
add({
id: LINE,
type: 'line',
source: SOURCE,
filter: ['all', isLine, notArea, notNear],
layout: lineLayout,
paint: linePaint,
});
// A dash cannot follow a property, so the lines only near the route have a layer of their own, as on Leaflet.
add({
id: LINE_NEAR,
type: 'line',
source: SOURCE,
filter: ['all', isLine, notArea, isNear],
layout: lineLayout,
paint: { ...linePaint, 'line-dasharray': [2, 1.6] },
});
add({ id: PINS_AREA, type: 'symbol', source: SOURCE, filter: ['all', isPoint, inArea], layout: pinLayout, ...area });
add({ id: PINS, type: 'symbol', source: SOURCE, filter: ['all', isPoint, notArea], layout: pinLayout });
}
/** The paddle layers this map has now; a style still loading or one rebuilt by a basemap change may have none. */
function presentHitLayers(map: { getLayer?(id: string): unknown }): string[] {
return HIT_LAYERS.filter((id) => map.getLayer?.(id));
}
/**
* Whether a click at `point` landed on a paddle object. The map's own click
* handler asks this first, so picking an obstacle opens its card instead of
* also setting a waypoint under it.
*/
export function isPaddleHit<Point>(
map: {
getLayer?(id: string): unknown;
queryRenderedFeatures?(point: Point, options: { layers: string[] }): unknown[];
},
point: Point
): boolean {
if (typeof map.getLayer !== 'function' || typeof map.queryRenderedFeatures !== 'function') return false;
const layers = presentHitLayers(map);
return layers.length > 0 && map.queryRenderedFeatures(point, { layers }).length > 0;
}
/**
* PAD-05B on a GL map (MapLibre or Mapbox, the 2D planner and the 3D
* terrain alike): the paddle objects as lines on a white casing and as round
* glyph markers, in the same look as on Leaflet, the ones only in the area
* from PADDLE_AREA_MIN_ZOOM on. Kept through a basemap change, which rebuilds
* the style and drops them, and removed with the layer.
*/
export function usePaddleLayerGL(map: PaddleGlMap | null, layer: PaddleMapLayer | undefined) {
const onSelect = useRef(layer?.onSelect);
useEffect(() => {
onSelect.current = layer?.onSelect;
}, [layer?.onSelect]);
const items = layer?.items;
const selectedRef = layer?.selectedRef ?? null;
const selectedKey = layer?.selectedKey ?? null;
useEffect(() => {
if (!map || !items?.length) return;
let gone = false;
const data = collection(items, selectedRef, selectedKey);
let applied: GeoJSON.FeatureCollection | null = null;
const apply = () => {
try {
const source = map.getSource(SOURCE) as { setData?: (next: GeoJSON.FeatureCollection) => void } | undefined;
// Every image the map takes fires styledata again; data already handed over is not sent twice.
if (source?.setData && applied === data) return;
addImages(map, items, () => gone);
if (source?.setData) source.setData(data);
else addLayers(map, data);
applied = data;
} catch {
// A style still loading takes the layers on its next styledata event.
}
};
apply();
map.on('styledata', apply);
return () => {
gone = true;
map.off('styledata', apply);
};
}, [items, map, selectedRef, selectedKey]);
useEffect(() => {
if (!map) return;
// One handler for all of them, so a marker over a line picks the marker's object, never both.
const click = (event: MapMouseEvent) => {
const layers = presentHitLayers(map);
if (layers.length === 0) return;
const [hit] = map.queryRenderedFeatures(event.point, { layers });
const ref: unknown = hit?.properties?.ref;
if (typeof ref === 'string') onSelect.current?.(ref);
};
const enter = () => {
map.getCanvas().style.cursor = 'pointer';
};
const leave = () => {
map.getCanvas().style.cursor = '';
};
map.on('click', click);
for (const id of HIT_LAYERS) {
map.on('mouseenter', id, enter);
map.on('mouseleave', id, leave);
}
return () => {
map.off('click', click);
for (const id of HIT_LAYERS) {
map.off('mouseenter', id, enter);
map.off('mouseleave', id, leave);
}
};
}, [map]);
// Without objects to show, the layers go; the next objects bring them back.
useEffect(() => {
if (!map || items?.length) return;
try {
for (const id of [...LAYERS].reverse()) if (map.getLayer(id)) map.removeLayer(id);
if (map.getSource(SOURCE)) map.removeSource(SOURCE);
} catch {
// A map torn down meanwhile has nothing left to remove.
}
}, [items, map]);
}
@@ -0,0 +1,300 @@
/**
* FE-HOOK-PADDLESYNC-001 to FE-HOOK-PADDLESYNC-008: how the planner keeps the
* paddle obstacle store in step with the paddle tour it has open.
*
* The hook asks the repo for the tiles around the line, snapped outward to a
* 0.05 degree grid, relates what came back to the line as it is now and
* publishes both, with the length the relations were measured along. A point
* moved inside the same tiles asks nothing new, an answer for an area the line
* has left is dropped, and with no paddle tour open, or a line too short to
* paddle, the store is emptied.
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PaddleCoverageRegion, PaddleFeature, PaddleGeometry, PaddleObstacleKind } from '@trek/shared';
import {
PaddleObstaclesOfflineError,
paddleObstacleRepo,
type PaddleObstaclesAround,
type Tile,
} from '../../../repo/paddleObstacleRepo';
import {
clearPaddleObstacles,
publishPaddleObstacles,
selectPaddleObstacle,
usePaddleObstacleStore,
} from '../../../store/paddleObstacleStore';
import { usePaddleObstacleSync } from './usePaddleObstacleSync';
// Only the request is answered here: the offline error and the tile maths stay the repo's own.
vi.mock('../../../repo/paddleObstacleRepo', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../repo/paddleObstacleRepo')>();
return { ...actual, paddleObstacleRepo: { ...actual.paddleObstacleRepo, inTiles: vi.fn() } };
});
type Line = readonly (readonly [number, number])[];
interface Props {
line: Line | null;
active: boolean;
}
/** A kilometre of river running north, [lat, lng] as the planner draws it. */
const LINE: Line = [
[46.5, 11.8],
[46.51, 11.8],
];
/** Its first point dragged 100 m east: still inside the same snapped tiles. */
const MOVED: Line = [
[46.5, 11.8013],
[46.51, 11.8],
];
/** A line in another area altogether. */
const FAR_LINE: Line = [
[47, 12],
[47.01, 12],
];
/** The tile LINE (and MOVED) asks for, snapped outward to the 0.05 degree grid. */
const LINE_TILE: Tile = { south: 46.45, west: 11.75, north: 46.55, east: 11.85 };
const FAR_TILE: Tile = { south: 46.95, west: 11.95, north: 47.05, east: 12.05 };
const feature = (ref: string, kind: PaddleObstacleKind, geometry: PaddleGeometry): PaddleFeature => ({
ref,
kind,
group_key: null,
name: null,
geometry,
tags: {},
tag_hash: `${ref}-hash`,
region_id: 'bavaria',
});
/** Across the river halfway down LINE; geometry is [lng, lat], as OSM gives it. */
const WEIR = feature('way/1', 'weir', {
type: 'LineString',
coordinates: [
[11.799, 46.505],
[11.801, 46.505],
],
});
/** On the east bank, 100 m off LINE: near it, and on the line once its first point moves east. */
const SLIPWAY = feature('node/2', 'slipway', { type: 'Point', coordinates: [11.8013, 46.502] });
/** Several kilometres east: read with the area, related to no line. */
const DAM = feature('way/3', 'dam', { type: 'Point', coordinates: [11.9, 46.505] });
/** On FAR_LINE. */
const LOCK = feature('node/4', 'lock', { type: 'Point', coordinates: [12, 47.005] });
const REGION: PaddleCoverageRegion = {
id: 'bavaria',
name: 'Bavaria',
source_timestamp: '2026-09-01T00:00:00Z',
imported_at: '2026-09-02T08:00:00Z',
};
/** What the repo reads around LINE: one region only partly covering it, and cut at the cap. */
const ANSWER: PaddleObstaclesAround = {
features: [WEIR, SLIPWAY, DAM],
coverage: 'partial',
regions: [REGION],
truncated: true,
};
const FAR_ANSWER: PaddleObstaclesAround = {
features: [LOCK],
coverage: 'covered',
regions: [REGION],
truncated: false,
};
const inTiles = vi.mocked(paddleObstacleRepo.inTiles);
const state = () => usePaddleObstacleStore.getState();
function renderSync(props: Props) {
return renderHook(({ line, active }: Props) => usePaddleObstacleSync(line, active), { initialProps: props });
}
/** A request the test answers by hand, so the state while it is on its way can be read. */
function deferred() {
let resolve: (value: PaddleObstaclesAround) => void = () => undefined;
let reject: (reason: unknown) => void = () => undefined;
const promise = new Promise<PaddleObstaclesAround>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
/** Lets a settled request run its handlers and the store follow. */
async function drain() {
await act(async () => {
for (let i = 0; i < 5; i += 1) await Promise.resolve();
});
}
/** What a tour opened before left in the store, so a test can see it emptied. */
function seed() {
publishPaddleObstacles({
status: 'ready',
features: [WEIR],
related: [],
routeLengthM: 500,
coverage: 'covered',
regions: [REGION],
truncated: false,
});
}
describe('usePaddleObstacleSync', () => {
beforeEach(() => {
inTiles.mockReset();
inTiles.mockResolvedValue(ANSWER);
clearPaddleObstacles();
});
it('FE-HOOK-PADDLESYNC-001: publishes loading while asking, then the objects, the related ones and the coverage', async () => {
const pending = deferred();
inTiles.mockReturnValueOnce(pending.promise);
renderSync({ line: LINE, active: true });
expect(inTiles).toHaveBeenCalledWith([LINE_TILE], expect.any(AbortSignal));
expect(state()).toMatchObject({
status: 'loading',
features: [],
related: [],
coverage: 'none',
regions: [],
truncated: false,
});
expect(state().routeLengthM).toBeCloseTo(1111.95, 1);
pending.resolve(ANSWER);
await drain();
expect(state()).toMatchObject({
status: 'ready',
features: [WEIR, SLIPWAY, DAM],
coverage: 'partial',
regions: [REGION],
truncated: true,
});
// In the order the line reaches them; the dam is too far off to relate.
expect(state().related).toMatchObject([
{ feature: { ref: 'node/2' }, relation: 'near' },
{ feature: { ref: 'way/1' }, relation: 'on_route', distanceM: 0, alongM: 556 },
]);
expect(state().routeLengthM).toBeCloseTo(1111.95, 1);
expect(inTiles).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-PADDLESYNC-002: offline it reports the data out of reach, never a route free of obstacles', async () => {
inTiles.mockRejectedValue(new PaddleObstaclesOfflineError());
renderSync({ line: LINE, active: true });
await drain();
expect(state()).toMatchObject({ status: 'offline', features: [], related: [], coverage: 'none', regions: [] });
expect(state().routeLengthM).toBeCloseTo(1111.95, 1);
});
it('FE-HOOK-PADDLESYNC-003: a failed request reads as an error, not as offline', async () => {
inTiles.mockRejectedValue(new Error('502'));
renderSync({ line: LINE, active: true });
await drain();
expect(state()).toMatchObject({ status: 'error', features: [], related: [], coverage: 'none' });
});
it.each<[string, Props]>([
['switched off', { line: LINE, active: false }],
['without a line', { line: null, active: true }],
['for a line of one point', { line: [[46.5, 11.8]], active: true }],
])('FE-HOOK-PADDLESYNC-004: asks for nothing and empties the store %s', async (_label, props) => {
seed();
renderSync(props);
await drain();
expect(inTiles).not.toHaveBeenCalled();
expect(state()).toMatchObject({ status: 'idle', features: [], related: [], routeLengthM: 0, regions: [] });
});
it('FE-HOOK-PADDLESYNC-005: a point moved inside the same tiles asks nothing new, a line that leaves them asks again', async () => {
const far = deferred();
inTiles.mockResolvedValueOnce(ANSWER).mockReturnValueOnce(far.promise);
const { rerender } = renderSync({ line: LINE, active: true });
await drain();
selectPaddleObstacle('way/1');
rerender({ line: MOVED, active: true });
await drain();
expect(inTiles).toHaveBeenCalledTimes(1);
// Related to the line as it is now: the slipway is on it, and the open card stays open.
expect(state()).toMatchObject({ status: 'ready', selectedRef: 'way/1' });
expect(state().related).toMatchObject([
{ feature: { ref: 'node/2' }, relation: 'on_route' },
{ feature: { ref: 'way/1' }, relation: 'on_route' },
]);
expect(state().routeLengthM).toBeCloseTo(1116.39, 1);
rerender({ line: FAR_LINE, active: true });
expect(inTiles).toHaveBeenCalledTimes(2);
expect(inTiles).toHaveBeenLastCalledWith([FAR_TILE], expect.any(AbortSignal));
// The old area's objects are not shown around the new line while it loads.
expect(state()).toMatchObject({ status: 'loading', features: [], related: [], selectedRef: null });
far.resolve(FAR_ANSWER);
await drain();
expect(state()).toMatchObject({ status: 'ready', features: [LOCK], coverage: 'covered' });
expect(state().related).toMatchObject([{ feature: { ref: 'node/4' }, relation: 'on_route' }]);
});
it.each(['answers', 'fails'] as const)(
'FE-HOOK-PADDLESYNC-006: keeps the new area when the request for the old one %s late',
async (outcome) => {
const old = deferred();
inTiles.mockReturnValueOnce(old.promise).mockResolvedValueOnce(FAR_ANSWER);
const { rerender } = renderSync({ line: LINE, active: true });
rerender({ line: FAR_LINE, active: true });
await drain();
expect(inTiles.mock.calls[0]?.[1]?.aborted).toBe(true);
expect(state().status).toBe('ready');
if (outcome === 'answers') old.resolve(ANSWER);
else old.reject(new Error('502'));
await drain();
expect(state()).toMatchObject({ status: 'ready', features: [LOCK], coverage: 'covered' });
}
);
it('FE-HOOK-PADDLESYNC-007: switching the paddle tour off empties the store and closes the open card', async () => {
const { rerender } = renderSync({ line: LINE, active: true });
await drain();
selectPaddleObstacle('way/1');
expect(state().status).toBe('ready');
rerender({ line: LINE, active: false });
expect(state()).toMatchObject({ status: 'idle', features: [], related: [], routeLengthM: 0, selectedRef: null });
expect(inTiles).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-PADDLESYNC-008: empties the store when the planner closes', async () => {
const { unmount } = renderSync({ line: LINE, active: true });
await drain();
selectPaddleObstacle('way/1');
expect(state()).toMatchObject({ status: 'ready', selectedRef: 'way/1' });
unmount();
expect(state()).toEqual({
status: 'idle',
features: [],
related: [],
routeLengthM: 0,
coverage: 'none',
regions: [],
truncated: false,
selectedRef: null,
focus: null,
});
});
});
@@ -0,0 +1,86 @@
import { measuredRouteLengthM, relateObstaclesToRoute, type PaddleCoverageState } from '@trek/shared';
import { useEffect, useMemo, useState } from 'react';
import {
PaddleObstaclesOfflineError,
paddleObstacleRepo,
tilesAround,
type PaddleObstaclesAround,
type Tile,
} from '../../../repo/paddleObstacleRepo';
import {
clearPaddleObstacles,
publishPaddleObstacles,
type PaddleObstacleStatus,
} from '../../../store/paddleObstacleStore';
type Line = readonly (readonly [number, number])[];
/** The area a line asks for, as tiles snapped outward to a 0.05 degree grid, so moving a point a little asks nothing new. */
function tilesOf(line: Line): Tile[] {
const snap = (value: number, outward: 'down' | 'up') =>
(outward === 'down' ? Math.floor(value * 20) : Math.ceil(value * 20)) / 20;
return tilesAround(line).map((tile) => ({
south: snap(tile.south, 'down'),
west: snap(tile.west, 'down'),
north: snap(tile.north, 'up'),
east: snap(tile.east, 'up'),
}));
}
interface Loaded {
key: string;
status: PaddleObstacleStatus;
data: PaddleObstaclesAround | null;
}
/**
* PAD-05B: keeps the obstacle store in step with the paddle tour open in the
* planner. It asks for the area around the line (again only when the line
* leaves it), relates what came back to the line as it is now, and publishes
* both; with no paddle tour open the store is emptied.
*/
export function usePaddleObstacleSync(line: Line | null, active: boolean) {
const key = active && line && line.length >= 2 ? JSON.stringify(tilesOf(line)) : null;
const [loaded, setLoaded] = useState<Loaded | null>(null);
useEffect(() => {
if (!key) return;
const controller = new AbortController();
paddleObstacleRepo
.inTiles(JSON.parse(key) as Tile[], controller.signal)
.then((data) => {
if (!controller.signal.aborted) setLoaded({ key, status: 'ready', data });
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setLoaded({ key, status: err instanceof PaddleObstaclesOfflineError ? 'offline' : 'error', data: null });
});
return () => controller.abort();
}, [key]);
const current = loaded && loaded.key === key ? loaded : null;
const related = useMemo(
() => (current?.data && line ? relateObstaclesToRoute(line, current.data.features) : []),
[current, line]
);
const routeLengthM = useMemo(() => (line ? measuredRouteLengthM(line) : 0), [line]);
useEffect(() => {
if (!key) {
clearPaddleObstacles();
return;
}
const coverage: PaddleCoverageState = current?.data?.coverage ?? 'none';
publishPaddleObstacles({
status: current?.status ?? 'loading',
features: current?.data?.features ?? [],
related,
routeLengthM,
coverage,
regions: current?.data?.regions ?? [],
truncated: current?.data?.truncated ?? false,
});
}, [current, key, related, routeLengthM]);
useEffect(() => () => clearPaddleObstacles(), []);
}
@@ -0,0 +1,23 @@
import type { PaddleLock } from '@trek/shared';
import { useCallback } from 'react';
import { setPortageExtraMinutes, setWaypointLock, togglePortageLeg } from '../planner/tourRouteActions';
import type { TourPlannerController } from '../planner/useTourPlanner';
/** The paddle edits of the planner's waypoint rows, each one step of its history. */
export function usePaddleWaypointActions(planner: TourPlannerController) {
const { editPaddlePoints } = planner;
return {
togglePortage: useCallback(
(id: string) => editPaddlePoints((points) => togglePortageLeg(points, id)),
[editPaddlePoints]
),
setPortageExtra: useCallback(
(id: string, minutes: number | null) => editPaddlePoints((points) => setPortageExtraMinutes(points, id, minutes)),
[editPaddlePoints]
),
setLock: useCallback(
(id: string, lock: PaddleLock | null) => editPaddlePoints((points) => setWaypointLock(points, id, lock)),
[editPaddlePoints]
),
};
}
@@ -0,0 +1,270 @@
/**
* FE-HOOK-TOURWIND-001 to FE-HOOK-TOURWIND-010: the wind forecast for a paddle
* tour on its day, at its start (PAD-08).
*
* Pinned: without a day, a date or a start place it says which is missing and
* asks nothing; one request per place, day and retry, loading until it
* answers; offline and a failure each say so, and a retry asks again; the day
* shown is the first still to come, else the first of the tour, or the one
* chosen while the tour is on it; an answer for a day no longer shown is
* dropped, and leaving the view aborts the request.
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import type { WindForecastResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { WindOfflineError } from '../../../repo/windRepo';
import { useTripStore } from '../../../store/tripStore';
import type { AssignmentsMap, Day } from '../../../types';
import { useTourWind } from './useTourWind';
type Forecast = (lat: number, lng: number, date: string, signal?: AbortSignal) => Promise<WindForecastResponse>;
const repo = vi.hoisted(() => ({ forecast: vi.fn<Forecast>() }));
vi.mock('../../../repo/windRepo', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../repo/windRepo')>()),
windRepo: repo,
}));
const TODAY = '2026-07-01';
const START = { lat: 47.5, lng: 9.7 };
const tour = buildPlace({ name: 'Lake loop' });
function forecastFor(date: string): WindForecastResponse {
return {
status: 'forecast',
date,
lat: START.lat,
lng: START.lng,
timezone: 'Europe/Berlin',
source: 'Open-Meteo',
fetched_at: `${TODAY}T06:00:00Z`,
horizon_days: 16,
hours: [{ hour: 9, speed_kmh: 12, gusts_kmh: 25, direction_deg: 240 }],
day: { speed_max_kmh: 18, gusts_max_kmh: 31, direction_dominant_deg: 250 },
};
}
/** The trip's days, with the tour on the ones in `tourOn`. */
function seedTour(days: Day[], tourOn: Day[] = days): void {
const assignments: AssignmentsMap = {};
for (const day of tourOn) assignments[String(day.id)] = [buildAssignment({ day_id: day.id, place: tour })];
seedStore(useTripStore, { days, assignments });
}
/** A request that answers only when the test says so. */
function deferred() {
let resolve: (forecast: WindForecastResponse) => void = () => undefined;
const promise = new Promise<WindForecastResponse>((done) => {
resolve = done;
});
return { promise, resolve };
}
/** A request that, like the real one, fails as soon as its signal is aborted. */
function abortable(_lat: number, _lng: number, _date: string, signal?: AbortSignal): Promise<WindForecastResponse> {
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
});
}
function signalOf(call: number): AbortSignal | undefined {
return repo.forecast.mock.calls[call]?.[3];
}
/** Lets every pending promise reaction run, a late answer included. */
async function settle(): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(`${TODAY}T10:00:00Z`));
resetAllStores();
repo.forecast.mockReset();
repo.forecast.mockImplementation(async (_lat, _lng, date) => forecastFor(date));
});
afterEach(() => {
vi.useRealTimers();
});
describe('useTourWind (PAD-08)', () => {
it('FE-HOOK-TOURWIND-001: without a day the tour is on, it says so and asks nothing', () => {
seedStore(useTripStore, { days: undefined });
const unloaded = renderHook(() => useTourWind(tour.id, START));
expect(unloaded.result.current.state).toEqual({ kind: 'no_day' });
unloaded.unmount();
const day = buildDay({ date: '2026-07-04' });
seedTour([day], []);
const offDay = renderHook(() => useTourWind(tour.id, START));
expect(offDay.result.current.state).toEqual({ kind: 'no_day' });
expect(offDay.result.current.day).toBeNull();
expect(offDay.result.current.tourDays).toEqual([]);
offDay.unmount();
seedTour([day]);
const noTour = renderHook(() => useTourWind(null, START));
expect(noTour.result.current.state).toEqual({ kind: 'no_day' });
expect(repo.forecast).not.toHaveBeenCalled();
});
it('FE-HOOK-TOURWIND-002: a day without a date says so and asks nothing', () => {
const day = buildDay({ date: null });
seedTour([day]);
const { result } = renderHook(() => useTourWind(tour.id, START));
expect(result.current.state).toEqual({ kind: 'no_date' });
expect(result.current.day).toEqual({ day, index: 0 });
expect(repo.forecast).not.toHaveBeenCalled();
});
it('FE-HOOK-TOURWIND-003: without a start place it says so and asks nothing', () => {
const day = buildDay({ date: '2026-07-04' });
seedTour([day]);
const { result } = renderHook(() => useTourWind(tour.id, null));
expect(result.current.state).toEqual({ kind: 'no_place' });
expect(result.current.day).toEqual({ day, index: 0 });
expect(repo.forecast).not.toHaveBeenCalled();
});
it('FE-HOOK-TOURWIND-004: loads the forecast for the start on the tour day, once per place and day', async () => {
seedTour([buildDay({ date: '2026-07-04' })]);
const pending = deferred();
repo.forecast.mockReturnValueOnce(pending.promise);
const { result, rerender } = renderHook(({ start }) => useTourWind(tour.id, start), {
initialProps: { start: START },
});
expect(result.current.state).toEqual({ kind: 'loading' });
expect(repo.forecast).toHaveBeenCalledWith(47.5, 9.7, '2026-07-04', expect.any(AbortSignal));
await act(async () => {
pending.resolve(forecastFor('2026-07-04'));
await pending.promise;
});
expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') });
rerender({ start: { ...START } });
expect(repo.forecast).toHaveBeenCalledTimes(1);
expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') });
rerender({ start: { lat: 47.6, lng: 9.8 } });
expect(result.current.state).toEqual({ kind: 'loading' });
await waitFor(() => expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') }));
expect(repo.forecast).toHaveBeenCalledTimes(2);
expect(repo.forecast).toHaveBeenLastCalledWith(47.6, 9.8, '2026-07-04', expect.any(AbortSignal));
});
it('FE-HOOK-TOURWIND-005: offline it says so instead of failing', async () => {
seedTour([buildDay({ date: '2026-07-04' })]);
repo.forecast.mockRejectedValueOnce(new WindOfflineError());
const { result } = renderHook(() => useTourWind(tour.id, START));
expect(result.current.state).toEqual({ kind: 'loading' });
await waitFor(() => expect(result.current.state).toEqual({ kind: 'offline' }));
});
it('FE-HOOK-TOURWIND-006: a failure says so, and a retry asks again', async () => {
seedTour([buildDay({ date: '2026-07-04' })]);
repo.forecast.mockRejectedValueOnce(new Error('Request failed with status code 502'));
const { result } = renderHook(() => useTourWind(tour.id, START));
await waitFor(() => expect(result.current.state).toEqual({ kind: 'error' }));
act(() => result.current.retry());
expect(result.current.state).toEqual({ kind: 'loading' });
await waitFor(() => expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') }));
expect(repo.forecast).toHaveBeenCalledTimes(2);
expect(repo.forecast).toHaveBeenNthCalledWith(2, 47.5, 9.7, '2026-07-04', expect.any(AbortSignal));
});
it('FE-HOOK-TOURWIND-007: shows the first day still to come, else the first day of the tour', async () => {
const past = buildDay({ date: '2026-06-20' });
const undated = buildDay({ date: null });
const today = buildDay({ date: TODAY });
const later = buildDay({ date: '2026-07-04' });
seedTour([past, undated, today, later]);
const upcoming = renderHook(() => useTourWind(tour.id, START));
expect(upcoming.result.current.day).toEqual({ day: today, index: 2 });
expect(upcoming.result.current.tourDays.map(({ day }) => day.id)).toEqual([
past.id,
undated.id,
today.id,
later.id,
]);
await waitFor(() => expect(upcoming.result.current.state).toEqual({ kind: 'ready', forecast: forecastFor(TODAY) }));
upcoming.unmount();
seedTour([past, undated, later], [past, undated]);
const gone = renderHook(() => useTourWind(tour.id, START));
expect(gone.result.current.day).toEqual({ day: past, index: 0 });
await waitFor(() =>
expect(gone.result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-06-20') })
);
});
it('FE-HOOK-TOURWIND-008: a tour on two days shows the chosen one, and falls back once the tour leaves it', async () => {
const first = buildDay({ date: '2026-07-04' });
const second = buildDay({ date: '2026-07-05' });
seedTour([first, second]);
const { result } = renderHook(() => useTourWind(tour.id, START));
expect(result.current.tourDays).toEqual([
{ day: first, index: 0 },
{ day: second, index: 1 },
]);
expect(result.current.day).toEqual({ day: first, index: 0 });
await waitFor(() => expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') }));
act(() => result.current.chooseDay(second.id));
expect(result.current.day).toEqual({ day: second, index: 1 });
expect(result.current.state).toEqual({ kind: 'loading' });
await waitFor(() => expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-05') }));
expect(repo.forecast).toHaveBeenLastCalledWith(47.5, 9.7, '2026-07-05', expect.any(AbortSignal));
act(() => seedTour([first, second], [first]));
expect(result.current.day).toEqual({ day: first, index: 0 });
await waitFor(() => expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-04') }));
expect(repo.forecast).toHaveBeenCalledTimes(3);
});
it('FE-HOOK-TOURWIND-009: an answer for a day no longer shown is dropped', async () => {
const first = buildDay({ date: '2026-07-04' });
const second = buildDay({ date: '2026-07-05' });
seedTour([first, second]);
const slow = deferred();
const fresh = deferred();
repo.forecast.mockReturnValueOnce(slow.promise).mockReturnValueOnce(fresh.promise);
const { result } = renderHook(() => useTourWind(tour.id, START));
act(() => result.current.chooseDay(second.id));
expect(signalOf(0)?.aborted).toBe(true);
expect(signalOf(1)?.aborted).toBe(false);
await act(async () => {
slow.resolve(forecastFor('2026-07-04'));
await slow.promise;
});
expect(result.current.state).toEqual({ kind: 'loading' });
await act(async () => {
fresh.resolve(forecastFor('2026-07-05'));
await fresh.promise;
});
expect(result.current.state).toEqual({ kind: 'ready', forecast: forecastFor('2026-07-05') });
});
it('FE-HOOK-TOURWIND-010: leaving the view aborts the request, and its late failure is dropped', async () => {
seedTour([buildDay({ date: '2026-07-04' })]);
repo.forecast.mockImplementationOnce(abortable);
const { result, unmount } = renderHook(() => useTourWind(tour.id, START));
expect(result.current.state).toEqual({ kind: 'loading' });
expect(signalOf(0)?.aborted).toBe(false);
unmount();
expect(signalOf(0)?.aborted).toBe(true);
// The rejection the abort causes reaches the hook after it is gone, and nothing is set from it.
await settle();
expect(repo.forecast).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,81 @@
import type { WindForecastResponse } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { WindOfflineError, windRepo } from '../../../repo/windRepo';
import { useTripStore } from '../../../store/tripStore';
import type { Day } from '../../../types';
import { useTourDays, type TourDay } from '../tourDays';
export type TourWindState =
| { kind: 'no_day' }
| { kind: 'no_date' }
| { kind: 'no_place' }
| { kind: 'loading' }
| { kind: 'offline' }
| { kind: 'error' }
| { kind: 'ready'; forecast: WindForecastResponse };
const NO_DAYS: Day[] = [];
/** The day to show first: the first one still to come, else the first of the tour. */
function firstDayOf(tourDays: readonly TourDay[], today: string): TourDay | null {
return tourDays.find(({ day }) => !!day.date && day.date >= today) ?? tourDays[0] ?? null;
}
/**
* PAD-08: the wind forecast for a paddle tour on the day it is planned for,
* at its start. A tour on several days shows one at a time, the one chosen,
* never another day without saying so. Without a day, a date, a place or a
* connection it says which is missing; nothing here changes the tour.
*/
export function useTourWind(placeId: number | null, start: { lat: number; lng: number } | null) {
const days = useTripStore((state) => state.days) ?? NO_DAYS;
const tourDays = useTourDays(placeId ?? -1, days);
const today = new Date().toISOString().slice(0, 10);
const [chosenDayId, setChosenDayId] = useState<number | null>(null);
const day = useMemo(
() => tourDays.find((entry) => entry.day.id === chosenDayId) ?? firstDayOf(tourDays, today),
[chosenDayId, today, tourDays]
);
const [attempt, setAttempt] = useState(0);
const lat = start?.lat ?? null;
const lng = start?.lng ?? null;
const date = day?.day.date ?? null;
// One request per place, day and retry; the answer is kept with the key it was asked for.
const key = date && lat !== null && lng !== null ? `${lat},${lng},${date},${attempt}` : null;
const [result, setResult] = useState<{ key: string; state: TourWindState } | null>(null);
useEffect(() => {
if (!key || !date || lat === null || lng === null) return;
const controller = new AbortController();
windRepo
.forecast(lat, lng, date, controller.signal)
.then((forecast) => {
if (!controller.signal.aborted) setResult({ key, state: { kind: 'ready', forecast } });
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setResult({ key, state: { kind: err instanceof WindOfflineError ? 'offline' : 'error' } });
});
return () => controller.abort();
}, [date, key, lat, lng]);
const state: TourWindState = !day
? { kind: 'no_day' }
: !date
? { kind: 'no_date' }
: lat === null || lng === null
? { kind: 'no_place' }
: result && result.key === key
? result.state
: { kind: 'loading' };
return {
state,
day,
tourDays,
chooseDay: setChosenDayId,
retry: useCallback(() => setAttempt((value) => value + 1), []),
};
}
export type TourWind = ReturnType<typeof useTourWind>;
@@ -0,0 +1,508 @@
/**
* FE-HOOK-TRACKPADDLE-001 to FE-HOOK-TRACKPADDLE-014: the portages, locks and
* speeds laid onto a recorded paddle track, as the planner's card and map
* share them.
*
* The hook loads the marks of the open paddle import into the mark store, lays
* them along the length of the track and reckons the paddle time from them. A
* portage takes two clicks on the line, a lock one. Saving goes through the
* repo with what the store holds, takes in what came back and says how it
* went; nothing is saved for someone who may not change the tour, and the
* track itself is never touched.
*/
import { act, renderHook } from '@testing-library/react';
import type { TourDetailResponse, TourListItem, TourPaddlePlanRequest, TrackMarks } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores } from '../../../../tests/helpers/store';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { loadTrackMarks, pickOnTrack, refuseTrackPick, useTrackMarkToolStore } from '../../../store/trackMarkToolStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { useTrackPaddlePlan } from './useTrackPaddlePlan';
type UpdatePaddlePlan = (
tripId: number | string,
placeId: number,
body: TourPaddlePlanRequest
) => Promise<TourDetailResponse>;
const repo = vi.hoisted(() => ({ updatePaddlePlan: vi.fn<UpdatePaddlePlan>() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
type Options = Parameters<typeof useTrackPaddlePlan>[0];
/** A portage from 2 to 3 km and a lock at 6 km with both its times entered. */
const MARKS: TrackMarks = {
portages: [{ from_m: 2000, to_m: 3000, extra_minutes: null }],
locks: [{ at_m: 6000, wait_minutes: 10, transit_minutes: 5 }],
};
const NO_MARKS: TrackMarks = { portages: [], locks: [] };
/** A kayak import of the trip, with the marks above and no speeds of its own. */
function paddleTour(overrides: Partial<TourListItem> = {}): TourListItem {
return {
place_id: 7,
name: 'Altmühl from Treuchtlingen',
tour_type: 'kayak',
distance: 12,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: null,
planned: false,
caution: false,
boat_type: 'kayak',
water_context: 'river',
paddle_speed_kmh: null,
portage_speed_kmh: null,
track_marks: MARKS,
...overrides,
};
}
const TOUR = paddleTour();
const HIKE: TourListItem = { ...TOUR, tour_type: 'hike', boat_type: null, water_context: null, track_marks: null };
/** The planner with a recorded track open read-only, measured at this length, or with nothing open. */
function planner(tour: TourListItem | null, distanceKm = 12): TourPlannerController {
return {
mode: tour ? { type: 'view-gpx', placeId: tour.place_id, tour } : { type: 'neutral' },
readOnlyGpxAnalysis: tour ? { distanceKm } : null,
} as unknown as TourPlannerController;
}
function options(overrides: Partial<Options> = {}): Options {
return { tripId: 3, planner: planner(TOUR), canEdit: true, onSaved: vi.fn(), ...overrides };
}
function renderPlan(props: Options = options()) {
return renderHook((current: Options) => useTrackPaddlePlan(current), {
initialProps: props,
wrapper: TranslationProvider,
});
}
/** A save the test answers by hand, so the hook can be read while it is on its way. */
function deferred() {
let resolve: (value: TourDetailResponse) => void = () => undefined;
const promise = new Promise<TourDetailResponse>((done) => {
resolve = done;
});
return { promise, resolve };
}
beforeEach(() => {
vi.clearAllMocks();
repo.updatePaddlePlan.mockReset();
resetAllStores();
});
describe('useTrackPaddlePlan', () => {
it('FE-HOOK-TRACKPADDLE-001: loads the marks of the open paddle import and lays them along the track', () => {
const { result } = renderPlan();
expect(useTrackMarkToolStore.getState().placeId).toBe(7);
expect(result.current).toMatchObject({
active: true,
canEdit: true,
saving: false,
dirty: false,
pick: null,
pendingFromM: null,
refused: null,
totalM: 12000,
speeds: { paddle: null, portage: null },
recommendedPaddleSpeed: 4,
defaultPortageSpeed: 2,
});
expect(result.current.marks).toEqual(MARKS);
expect(result.current.layout).toEqual({
totalM: 12000,
stretches: [
{ kind: 'water', fromM: 0, toM: 2000, extraMinutes: null, startIndex: null },
{ kind: 'portage', fromM: 2000, toM: 3000, extraMinutes: null, startIndex: null },
{ kind: 'water', fromM: 3000, toM: 12000, extraMinutes: null, startIndex: null },
],
locks: [{ wait_minutes: 10, transit_minutes: 5, atM: 6000, bypassed: false, index: null }],
});
// 11 km on water at the kayak's 4 km/h, 1 km carried at 2 km/h plus 10 minutes, and the lock's 15.
expect(result.current.estimate).toEqual({
waterM: 11000,
landM: 1000,
portageCount: 1,
paddleSpeedKmh: 4,
portageSpeedKmh: 2,
waterMinutes: 165,
carryMinutes: 30,
portageExtraMinutes: 10,
lockMinutes: 15,
locksCounted: 1,
locksMissingTime: 0,
breakMinutes: 0,
estimateMinutes: 220,
complete: true,
});
});
it.each<[string, TourListItem | null, number]>([
['with nothing open', null, 0],
['for a hike import', HIKE, 12000],
])('FE-HOOK-TRACKPADDLE-002: shows no card and empties the store %s', (_label, tour, totalM) => {
loadTrackMarks(9, MARKS, { paddle: 5, portage: 3 });
const { result } = renderPlan(options({ planner: planner(tour) }));
expect(useTrackMarkToolStore.getState()).toMatchObject({ placeId: null, speeds: { paddle: null, portage: null } });
expect(result.current).toMatchObject({ active: false, totalM, marks: NO_MARKS, recommendedPaddleSpeed: null });
// No boat and no speed: the water time is unknown, never zero.
expect(result.current.estimate).toMatchObject({
paddleSpeedKmh: null,
waterMinutes: null,
estimateMinutes: 0,
complete: false,
});
});
it('FE-HOOK-TRACKPADDLE-003: marks a portage from two clicks in either order and a lock from one', () => {
const { result } = renderPlan();
act(() => result.current.startPick('portage'));
act(() => pickOnTrack(9000.4));
expect(result.current).toMatchObject({ pick: 'portage', pendingFromM: 9000.4, dirty: false });
expect(result.current.marks.portages).toHaveLength(1);
act(() => pickOnTrack(7999.6));
expect(result.current).toMatchObject({ pick: null, pendingFromM: null, dirty: true });
expect(result.current.marks.portages).toEqual([
{ from_m: 2000, to_m: 3000, extra_minutes: null },
{ from_m: 8000, to_m: 9000, extra_minutes: null },
]);
expect(result.current.layout.stretches.map((stretch) => [stretch.kind, stretch.fromM, stretch.toM])).toEqual([
['water', 0, 2000],
['portage', 2000, 3000],
['water', 3000, 8000],
['portage', 8000, 9000],
['water', 9000, 12000],
]);
expect(result.current.estimate).toMatchObject({
waterM: 10000,
landM: 2000,
portageCount: 2,
waterMinutes: 150,
carryMinutes: 60,
portageExtraMinutes: 20,
estimateMinutes: 245,
complete: true,
});
act(() => result.current.startPick('lock'));
act(() => pickOnTrack(4000.2));
expect(result.current.pick).toBeNull();
expect(result.current.marks.locks).toEqual([
{ at_m: 4000, wait_minutes: null, transit_minutes: null },
{ at_m: 6000, wait_minutes: 10, transit_minutes: 5 },
]);
// A lock without its times leaves the estimate incomplete rather than counting it as zero.
expect(result.current.estimate).toMatchObject({
locksCounted: 2,
locksMissingTime: 1,
lockMinutes: 15,
estimateMinutes: 245,
complete: false,
});
// A lock inside a portage is carried round: it counts with the portage, not again.
act(() => result.current.startPick('lock'));
act(() => pickOnTrack(8500));
expect(result.current.layout.locks.map((lock) => [lock.atM, lock.bypassed])).toEqual([
[4000, false],
[6000, false],
[8500, true],
]);
expect(result.current.estimate).toMatchObject({ locksCounted: 2, locksMissingTime: 1, estimateMinutes: 245 });
});
it('FE-HOOK-TRACKPADDLE-004: a tool pressed again turns off, and a refused click says why', () => {
const { result } = renderPlan();
act(() => result.current.startPick('lock'));
expect(result.current.pick).toBe('lock');
act(() => result.current.startPick('lock'));
expect(result.current.pick).toBeNull();
act(() => result.current.startPick('portage'));
act(() => pickOnTrack(2200));
act(() => pickOnTrack(2800));
expect(result.current).toMatchObject({ pick: 'portage', pendingFromM: null, refused: 'overlap', dirty: false });
expect(result.current.marks).toEqual(MARKS);
act(() => refuseTrackPick());
expect(result.current.refused).toBe('off_line');
act(() => result.current.startPick('lock'));
expect(result.current).toMatchObject({ pick: 'lock', refused: null });
});
it('FE-HOOK-TRACKPADDLE-005: takes the minutes entered for a portage and a lock, and removes either', () => {
const { result } = renderPlan();
act(() => result.current.edits.setPortageExtra(0, 25));
expect(result.current.marks.portages).toEqual([{ from_m: 2000, to_m: 3000, extra_minutes: 25 }]);
expect(result.current.dirty).toBe(true);
expect(result.current.estimate).toMatchObject({ portageExtraMinutes: 25, estimateMinutes: 235 });
// Zero is a time of its own; cleared, the starting value of 10 minutes applies again.
act(() => result.current.edits.setPortageExtra(0, 0));
expect(result.current.estimate).toMatchObject({ portageExtraMinutes: 0, estimateMinutes: 210 });
act(() => result.current.edits.setPortageExtra(0, null));
expect(result.current.estimate).toMatchObject({ portageExtraMinutes: 10, estimateMinutes: 220 });
act(() => result.current.edits.setLock(0, { wait_minutes: null, transit_minutes: 5 }));
expect(result.current.marks.locks).toEqual([{ at_m: 6000, wait_minutes: null, transit_minutes: 5 }]);
expect(result.current.estimate).toMatchObject({
lockMinutes: 5,
locksMissingTime: 1,
estimateMinutes: 210,
complete: false,
});
act(() => result.current.edits.setLock(0, null));
expect(result.current.marks.locks).toEqual([]);
expect(result.current.estimate).toMatchObject({
locksCounted: 0,
lockMinutes: 0,
estimateMinutes: 205,
complete: true,
});
act(() => result.current.edits.removePortage(0));
expect(result.current.marks).toEqual(NO_MARKS);
expect(result.current.layout.stretches).toEqual([
{ kind: 'water', fromM: 0, toM: 12000, extraMinutes: null, startIndex: null },
]);
expect(result.current.estimate).toMatchObject({ portageCount: 0, waterMinutes: 180, estimateMinutes: 180 });
});
it('FE-HOOK-TRACKPADDLE-006: reckons with the speeds saved on the tour, then with those entered', () => {
const saved = paddleTour({ paddle_speed_kmh: 5.5, portage_speed_kmh: 3 });
const { result } = renderPlan(options({ planner: planner(saved) }));
expect(result.current.speeds).toEqual({ paddle: 5.5, portage: 3 });
expect(result.current.estimate).toMatchObject({
paddleSpeedKmh: 5.5,
portageSpeedKmh: 3,
waterMinutes: 120,
carryMinutes: 20,
estimateMinutes: 165,
});
act(() => result.current.edits.setSpeeds({ paddle: 6 }));
expect(result.current).toMatchObject({ dirty: true, speeds: { paddle: 6, portage: 3 } });
expect(result.current.estimate).toMatchObject({ waterMinutes: 110, carryMinutes: 20, estimateMinutes: 155 });
act(() => result.current.edits.setSpeeds({ portage: 4 }));
expect(result.current.estimate).toMatchObject({ portageSpeedKmh: 4, carryMinutes: 15, estimateMinutes: 150 });
// Cleared, the suggested speed of the boat applies again.
act(() => result.current.edits.setSpeeds({ paddle: null }));
expect(result.current.speeds).toEqual({ paddle: null, portage: 4 });
expect(result.current.estimate).toMatchObject({ paddleSpeedKmh: 4, waterMinutes: 165, estimateMinutes: 205 });
});
it('FE-HOOK-TRACKPADDLE-007: a boat with no suggested speed leaves the water time open until one is entered', () => {
const { result } = renderPlan(options({ planner: planner(paddleTour({ boat_type: 'other' })) }));
expect(result.current.recommendedPaddleSpeed).toBeNull();
expect(result.current.estimate).toMatchObject({
paddleSpeedKmh: null,
waterMinutes: null,
carryMinutes: 30,
portageExtraMinutes: 10,
lockMinutes: 15,
estimateMinutes: 55,
complete: false,
});
act(() => result.current.edits.setSpeeds({ paddle: 4 }));
expect(result.current.estimate).toMatchObject({
paddleSpeedKmh: 4,
waterMinutes: 165,
estimateMinutes: 220,
complete: true,
});
});
it('FE-HOOK-TRACKPADDLE-008: saves through the repo, takes in what came back and hands the tour on', async () => {
const sent: TrackMarks = {
portages: [{ from_m: 2000, to_m: 3000, extra_minutes: null }],
locks: [
{ at_m: 4000, wait_minutes: null, transit_minutes: null },
{ at_m: 6000, wait_minutes: 10, transit_minutes: 5 },
],
};
const saved = paddleTour({ paddle_speed_kmh: 5, portage_speed_kmh: undefined, track_marks: sent });
const pending = deferred();
repo.updatePaddlePlan.mockReturnValueOnce(pending.promise);
// Whether the store was clean already when the tour was handed on to replace the open one.
const dirtyWhenHandedOn: boolean[] = [];
const onSaved = vi.fn<(tour: TourListItem) => void>(() => {
dirtyWhenHandedOn.push(useTrackMarkToolStore.getState().dirty);
});
const { result } = renderPlan(options({ onSaved }));
act(() => result.current.startPick('lock'));
act(() => pickOnTrack(4000));
act(() => result.current.edits.setSpeeds({ paddle: 5 }));
const saves: Promise<void>[] = [];
act(() => {
saves.push(result.current.save());
});
expect(result.current.saving).toBe(true);
// A second press while the first is on its way sends nothing more.
await act(async () => {
await result.current.save();
});
expect(repo.updatePaddlePlan).toHaveBeenCalledTimes(1);
expect(repo.updatePaddlePlan).toHaveBeenCalledWith(3, 7, {
paddle_speed_kmh: 5,
portage_speed_kmh: null,
track_marks: sent,
});
await act(async () => {
pending.resolve({ tour: saved, waypoints: [] });
await Promise.all(saves);
});
expect(result.current).toMatchObject({ saving: false, dirty: false, speeds: { paddle: 5, portage: null } });
expect(result.current.marks).toEqual(sent);
expect(onSaved).toHaveBeenCalledWith(saved);
expect(dirtyWhenHandedOn).toEqual([false]);
expect(toast.success).toHaveBeenCalledWith('Portages and locks saved');
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-HOOK-TRACKPADDLE-009: with everything removed, the saved tour carries no marks and the store none', async () => {
const saved = paddleTour({ track_marks: null });
repo.updatePaddlePlan.mockResolvedValueOnce({ tour: saved, waypoints: [] });
const onSaved = vi.fn<(tour: TourListItem) => void>();
const { result } = renderPlan(options({ onSaved }));
act(() => {
result.current.edits.removePortage(0);
result.current.edits.setLock(0, null);
});
await act(async () => {
await result.current.save();
});
expect(repo.updatePaddlePlan).toHaveBeenCalledWith(3, 7, {
paddle_speed_kmh: null,
portage_speed_kmh: null,
track_marks: NO_MARKS,
});
expect(result.current).toMatchObject({ dirty: false, marks: NO_MARKS });
expect(onSaved).toHaveBeenCalledWith(saved);
});
it('FE-HOOK-TRACKPADDLE-010: a failed save says so and keeps the unsaved marks to try again', async () => {
repo.updatePaddlePlan.mockRejectedValueOnce(new Error('offline'));
const onSaved = vi.fn<(tour: TourListItem) => void>();
const { result } = renderPlan(options({ onSaved }));
act(() => result.current.edits.setPortageExtra(0, 20));
await act(async () => {
await result.current.save();
});
expect(toast.error).toHaveBeenCalledWith('Saving did not work. Please try again.');
expect(toast.success).not.toHaveBeenCalled();
expect(onSaved).not.toHaveBeenCalled();
expect(result.current).toMatchObject({ saving: false, dirty: true });
expect(result.current.marks.portages).toEqual([{ from_m: 2000, to_m: 3000, extra_minutes: 20 }]);
});
it.each<[string, Partial<Options>, boolean]>([
['for someone who may not change the tour', { canEdit: false }, true],
['with no paddle import open', { planner: planner(null) }, false],
])('FE-HOOK-TRACKPADDLE-011: saves nothing %s', async (_label, overrides, active) => {
const { result } = renderPlan(options(overrides));
expect(result.current.active).toBe(active);
await act(async () => {
await result.current.save();
});
expect(repo.updatePaddlePlan).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
expect(result.current.saving).toBe(false);
});
it('FE-HOOK-TRACKPADDLE-012: discarding puts back the marks and speeds the tour was saved with', () => {
const { result } = renderPlan();
act(() => {
result.current.edits.removePortage(0);
result.current.edits.setSpeeds({ paddle: 6, portage: 3 });
result.current.startPick('lock');
});
expect(result.current).toMatchObject({ dirty: true, pick: 'lock' });
act(() => result.current.discard());
expect(result.current).toMatchObject({ dirty: false, pick: null, speeds: { paddle: null, portage: null } });
expect(result.current.marks).toEqual(MARKS);
expect(repo.updatePaddlePlan).not.toHaveBeenCalled();
});
it('FE-HOOK-TRACKPADDLE-013: with no paddle import open, discarding leaves the shared store alone', () => {
const { result } = renderPlan(options({ planner: planner(null) }));
act(() => loadTrackMarks(9, MARKS, { paddle: 5, portage: null }));
act(() => result.current.discard());
expect(useTrackMarkToolStore.getState()).toMatchObject({
placeId: 9,
marks: MARKS,
speeds: { paddle: 5, portage: null },
});
});
it('FE-HOOK-TRACKPADDLE-014: the marks follow the open tour when it is saved elsewhere, changed or left', () => {
const props = options();
const { result, rerender } = renderPlan(props);
act(() => result.current.edits.setPortageExtra(0, 30));
// The same tour, saved from another device meanwhile: its marks replace the unsaved ones.
rerender({ ...props, planner: planner(paddleTour({ track_marks: null, paddle_speed_kmh: 6 })) });
expect(result.current).toMatchObject({ active: true, dirty: false, speeds: { paddle: 6, portage: null } });
expect(result.current.marks).toEqual(NO_MARKS);
expect(result.current.estimate).toMatchObject({ waterMinutes: 120, estimateMinutes: 120, complete: true });
// Another import, from a list cached before tours carried their speeds.
const other = paddleTour({
place_id: 8,
boat_type: 'canoe',
paddle_speed_kmh: undefined,
portage_speed_kmh: undefined,
track_marks: { portages: [], locks: [{ at_m: 500, wait_minutes: 5, transit_minutes: 5 }] },
});
rerender({ ...props, planner: planner(other, 3) });
expect(useTrackMarkToolStore.getState().placeId).toBe(8);
expect(result.current).toMatchObject({ active: true, totalM: 3000, speeds: { paddle: null, portage: null } });
expect(result.current.estimate).toMatchObject({
waterMinutes: 45,
lockMinutes: 10,
estimateMinutes: 55,
complete: true,
});
// The shared store holding the marks of another tour: the card does not show them as this one's.
act(() => loadTrackMarks(9, null, { paddle: null, portage: null }));
expect(result.current.active).toBe(false);
rerender({ ...props, planner: planner(null) });
expect(result.current.active).toBe(false);
expect(useTrackMarkToolStore.getState()).toMatchObject({ placeId: null, marks: NO_MARKS });
});
});
@@ -0,0 +1,136 @@
import {
DEFAULT_PORTAGE_SPEED_KMH,
estimatePaddleTime,
paddleLayoutOfTrack,
recommendedPaddleSpeedKmh,
type TourListItem,
} from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../../i18n';
import { tourRepo } from '../../../repo/tourRepo';
import {
loadTrackMarks,
markTrackMarksSaved,
startTrackPick,
trackMarkEdits,
useTrackMarkToolStore,
type TrackPick,
} from '../../../store/trackMarkToolStore';
import { useToast } from '../../shared/Toast';
import type { TourPlannerController } from '../planner/useTourPlanner';
interface TrackPaddlePlanOptions {
tripId: number;
planner: TourPlannerController;
canEdit: boolean;
/** The tour as it stands after the save, for the lists and the map to take it in. */
onSaved: (tour: TourListItem) => Promise<void> | void;
}
/**
* PAD-03 and PAD-07 for a recorded paddle track: the portages and locks laid
* onto it and the speeds its estimate is reckoned with. The marks live in a
* store the map shares (it is clicked to place them); this loads them when a
* paddle import is opened, drops them when it is left, and saves them through
* the offline core. The track itself is never changed.
*/
export function useTrackPaddlePlan({ tripId, planner, canEdit, onSaved }: TrackPaddlePlanOptions) {
const { t } = useTranslation();
const toast = useToast();
const [saving, setSaving] = useState(false);
const tour = planner.mode.type === 'view-gpx' ? planner.mode.tour : null;
const paddleTour = tour?.tour_type === 'kayak' ? tour : null;
const totalM = (planner.readOnlyGpxAnalysis?.distanceKm ?? 0) * 1000;
const state = useTrackMarkToolStore(
useShallow((store) => ({
placeId: store.placeId,
marks: store.marks,
speeds: store.speeds,
dirty: store.dirty,
pick: store.pick,
pendingFromM: store.pendingFromM,
refused: store.refused,
}))
);
// The tour opened, saved elsewhere meanwhile, or left: the marks follow it, unsaved ones are dropped.
useEffect(() => {
if (!paddleTour) {
loadTrackMarks(null, null, { paddle: null, portage: null });
return;
}
loadTrackMarks(paddleTour.place_id, paddleTour.track_marks, {
paddle: paddleTour.paddle_speed_kmh ?? null,
portage: paddleTour.portage_speed_kmh ?? null,
});
}, [paddleTour]);
const active = !!paddleTour && state.placeId === paddleTour.place_id;
const layout = useMemo(() => paddleLayoutOfTrack(totalM, state.marks), [state.marks, totalM]);
const recommended = recommendedPaddleSpeedKmh(paddleTour?.boat_type ?? null);
const estimate = useMemo(
() =>
estimatePaddleTime(layout, {
paddleSpeedKmh: state.speeds.paddle ?? recommended,
portageSpeedKmh: state.speeds.portage,
breakMinutes: null,
}),
[layout, recommended, state.speeds.paddle, state.speeds.portage]
);
const save = useCallback(async () => {
if (!paddleTour || saving || !canEdit) return;
setSaving(true);
try {
const body = {
paddle_speed_kmh: state.speeds.paddle,
portage_speed_kmh: state.speeds.portage,
track_marks: state.marks,
};
const result = await tourRepo.updatePaddlePlan(tripId, paddleTour.place_id, body);
markTrackMarksSaved(result.tour.track_marks, {
paddle: result.tour.paddle_speed_kmh ?? null,
portage: result.tour.portage_speed_kmh ?? null,
});
await onSaved(result.tour);
toast.success(t('tours.paddle.marks.saved'));
} catch {
toast.error(t('tours.paddle.marks.failed'));
} finally {
setSaving(false);
}
}, [canEdit, onSaved, paddleTour, saving, state.marks, state.speeds, t, toast, tripId]);
const discard = useCallback(() => {
if (!paddleTour) return;
loadTrackMarks(paddleTour.place_id, paddleTour.track_marks, {
paddle: paddleTour.paddle_speed_kmh ?? null,
portage: paddleTour.portage_speed_kmh ?? null,
});
}, [paddleTour]);
return {
/** A recorded paddle track is open: the card shows. */
active,
canEdit,
saving,
marks: state.marks,
speeds: state.speeds,
dirty: state.dirty,
pick: state.pick,
pendingFromM: state.pendingFromM,
refused: state.refused,
totalM,
layout,
estimate,
recommendedPaddleSpeed: recommended,
defaultPortageSpeed: DEFAULT_PORTAGE_SPEED_KMH,
startPick: (pick: TrackPick) => startTrackPick(state.pick === pick ? null : pick),
edits: trackMarkEdits,
save,
discard,
};
}
export type TrackPaddlePlan = ReturnType<typeof useTrackPaddlePlan>;
@@ -0,0 +1,26 @@
import { useMemo } from 'react';
import { useTrackMarkToolStore } from '../../../store/trackMarkToolStore';
import { splitTrackByPortages } from '../../../utils/paddleTrackMarks';
import type { TourRoutePart } from '../planner/tourRouting';
import type { TourPlannerController } from '../planner/useTourPlanner';
/**
* The recorded paddle track in view, cut where portages are marked on it, as
* the parts the planner map draws: water as the plain line, each carried
* stretch on its own. Null while no paddle track with a portage is open.
*/
export function useTrackPortageParts(
planner: Pick<TourPlannerController, 'mode' | 'readOnlyGpxAnalysis'>
): TourRoutePart[] | null {
const placeId = planner.mode.type === 'view-gpx' ? planner.mode.placeId : null;
const portages = useTrackMarkToolStore((state) => (state.placeId === placeId ? state.marks.portages : null));
const line = planner.mode.type === 'view-gpx' ? planner.readOnlyGpxAnalysis?.routeCoordinates : undefined;
return useMemo(() => {
if (!line?.length || !portages?.length) return null;
return splitTrackByPortages(line, portages).map((part) => ({
coordinates: part.coordinates,
free: false,
...(part.land ? { land: true } : {}),
}));
}, [line, portages]);
}
@@ -348,7 +348,10 @@ export function TourDetailsSection({
<>
<TourDescriptionField planner={planner} locked={locked} />
<TourWebsiteField planner={planner} locked={locked} />
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
{/* A paddle tour is not graded on the hiking scale. */}
{!planner.isPaddle && (
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
)}
<TourTimeFields planner={planner} locked={locked} />
</>
)}
@@ -7,13 +7,21 @@ import { resetAllStores, seedStore } from '../../../../tests/helpers/store'
import { useSettingsStore } from '../../../store/settingsStore'
import { analyzeRouteGeometry } from '../../../utils/routeGeometry'
import type { TourPlannerController } from './useTourPlanner'
import type { PaddleChecklist } from '../paddle/usePaddleChecklist'
import type { Day } from '../../../types'
import { publishTourFerrataCrossings } from '../../../store/tourFerrataCrossingStore'
import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels'
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub'
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') })
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') })
// The checklist card is its own suite's; here it only has to sit where the rails put it.
vi.mock('../paddle/PaddleCheckCard', () => ({
PaddleCheckCard: ({ checklist }: { checklist: PaddleChecklist }) => (
<div data-testid="paddle-check-card" data-status={checklist.status} />
),
}))
const tour: TourListItem = {
place_id: 42,
@@ -43,6 +51,7 @@ const routeAnalysis = analyzeRouteGeometry(JSON.stringify([[48, 11, 500], [48.03
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
const state = {
...hikePlannerDefaults(),
canEdit: true,
canAssign: true,
isSaving: false,
@@ -534,9 +543,12 @@ describe('TourPlannerRail', () => {
fireEvent.dragEnd(rows[0], { dataTransfer: transfer });
expect(reorderWaypoint).not.toHaveBeenCalled();
const moveDown = screen.getAllByRole('button', { name: 'Move waypoint down' })[0];
expect(moveDown).toBeEnabled();
moveDown.focus();
// The row's menu opens from the keyboard and lands on its first action that can run.
const more = screen.getAllByRole('button', { name: 'More actions' })[0];
more.focus();
await user.keyboard(' ');
const moveDown = screen.getByRole('button', { name: 'Move waypoint down' });
expect(moveDown).toHaveFocus();
await user.keyboard(' ');
expect(moveWaypoint).toHaveBeenCalledWith('a', 1);
expect(reorderWaypoint).not.toHaveBeenCalled();
@@ -571,9 +583,8 @@ describe('TourPlannerRail', () => {
expect(screen.getByRole('button', { name: 'Maximum trail difficulty' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Back to Tours' })).toBeDisabled()
expect(screen.getAllByLabelText('Move waypoint up')).toHaveLength(2)
expect(screen.getAllByLabelText('Move waypoint up').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getAllByLabelText('Move waypoint down').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getAllByLabelText('More actions')).toHaveLength(2)
expect(screen.getAllByLabelText('More actions').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(screen.getAllByLabelText('Remove waypoint').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(within(screen.getByRole('status')).getByLabelText('Saving…')).toBeInTheDocument()
})
@@ -868,3 +879,65 @@ describe('TourPlannerRail', () => {
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
})
})
describe('the paddle checklist in the rails (PAD-06)', () => {
const checklist = { status: 'ready', select: vi.fn(), decide: vi.fn() } as unknown as PaddleChecklist
const kayak: TourListItem = { ...tour, tour_type: 'kayak', has_waypoints: false }
const geometry = JSON.stringify([[48, 11], [48.01, 11.02]])
const paddleEditor = (overrides: Partial<TourPlannerController> = {}) =>
planner({ mode: { type: 'new-draft' }, tourType: 'kayak', isPaddle: true, ...overrides })
const recorded = (recordedTour: TourListItem) => planner({
readOnlyGpxTour: { tour: recordedTour, routeGeometry: geometry },
readOnlyGpxAnalysis: analyzeRouteGeometry(geometry),
})
const safetyRow = () => screen.queryByRole('button', { name: /Paddling has risks/ })
it('PCR-001: a paddle tour shows its checklist after the control points and before the wind, the safety row above Save', () => {
render(<TourPlannerRail planner={paddleEditor()} paddleChecklist={checklist} />)
const card = screen.getByTestId('paddle-check-card')
expect(card).toHaveAttribute('data-status', 'ready')
expect(screen.getByTestId('tour-planner-empty-state').compareDocumentPosition(card)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(card.compareDocumentPosition(screen.getByTestId('paddle-wind-card'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
const safety = screen.getByRole('button', { name: /Paddling has risks/ })
expect(safety.closest('fieldset')).toBeNull()
expect(safety.closest('.overflow-y-auto')).toBeNull()
expect(safety.compareDocumentPosition(screen.getByRole('button', { name: 'Save' }))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
})
it('PCR-002: the safety row stays open while a save locks the rail', () => {
render(<TourPlannerRail planner={paddleEditor({ isSaving: true })} paddleChecklist={checklist} />)
expect(screen.getByRole('button', { name: /Paddling has risks/ })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
})
it('PCR-003: a hike shows neither the checklist nor the safety row', () => {
render(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' } })} paddleChecklist={checklist} />)
expect(screen.queryByTestId('paddle-check-card')).not.toBeInTheDocument()
expect(safetyRow()).not.toBeInTheDocument()
})
it('PCR-004: without a checklist a paddle tour still shows its safety row', () => {
render(<TourPlannerRail planner={paddleEditor()} />)
expect(screen.queryByTestId('paddle-check-card')).not.toBeInTheDocument()
expect(safetyRow()).toBeInTheDocument()
})
it('PCR-005: a recorded kayak track scrolls its checklist before the wind and keeps the safety row in a footer; a recorded hike neither', () => {
const { rerender } = render(<TourPlannerRail planner={recorded(kayak)} paddleChecklist={checklist} />)
const card = screen.getByTestId('paddle-check-card')
expect(card.closest('.overflow-y-auto')).not.toBeNull()
expect(card.compareDocumentPosition(screen.getByTestId('paddle-wind-card'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
const safety = screen.getByRole('button', { name: /Paddling has risks/ })
expect(safety.closest('.overflow-y-auto')).toBeNull()
expect(safety.closest('section')).toHaveAttribute('aria-label', kayak.name)
rerender(<TourPlannerRail planner={recorded({ ...tour, has_waypoints: false })} paddleChecklist={checklist} />)
expect(screen.queryByTestId('paddle-check-card')).not.toBeInTheDocument()
expect(safetyRow()).not.toBeInTheDocument()
})
})
@@ -17,8 +17,16 @@ import TourListRow from '../TourListRow'
import ElevationProfile from '../../shared/ElevationProfile'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
import { tourMetricFacts } from '../tourFacts'
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
import { TourDetailsSection, TourNameField } from './TourEditorFields'
import { PaddleCheckCard } from '../paddle/PaddleCheckCard'
import { PaddleSafetyRow } from '../paddle/PaddleSafety'
import { PaddleTimeCard } from '../paddle/PaddleTimeCard'
import { PaddleWindSection } from '../paddle/PaddleWindCard'
import { TrackPaddlePlanCards } from '../paddle/TrackPaddlePlanCard'
import type { PaddleChecklist } from '../paddle/usePaddleChecklist'
import type { TrackPaddlePlan } from '../paddle/useTrackPaddlePlan'
import { TourTypeFields } from './TourTypeFields'
import TourWaypointList from './TourWaypointList'
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
@@ -218,15 +226,25 @@ function NeutralRail({
)
}
/** Where a recorded track starts, the place its wind is asked for. */
function trackStart(line: readonly (readonly [number, number])[] | undefined): { lat: number; lng: number } | null {
const first = line?.[0]
return first ? { lat: first[0], lng: first[1] } : null
}
/** The rail for an imported tour: shown as it came in, measured, never edited. */
function GpxViewRail({
planner,
canEdit,
conversion,
trackPaddlePlan,
paddleChecklist,
}: {
planner: TourPlannerController
canEdit: boolean
conversion?: GpxConversion
trackPaddlePlan?: TrackPaddlePlan
paddleChecklist?: PaddleChecklist
}) {
const { t } = useTranslation()
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit)
@@ -239,7 +257,7 @@ function GpxViewRail({
tour.duration != null
? [
{
label: t('tours.planner.inspector.duration'),
label: movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
value: t('tours.durationMinutes', { count: Math.max(1, Math.round(tour.duration)) }),
},
]
@@ -263,6 +281,11 @@ function GpxViewRail({
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
{conversion && <TourConversionSection conversion={conversion} />}
{trackPaddlePlan && <TrackPaddlePlanCards plan={trackPaddlePlan} tour={tour} />}
{paddleChecklist && tour.tour_type === 'kayak' && <PaddleCheckCard checklist={paddleChecklist} />}
{tour.tour_type === 'kayak' && (
<PaddleWindSection placeId={tour.place_id} start={trackStart(analysis?.routeCoordinates)} />
)}
<RailCard icon={<Mountain size={14} strokeWidth={2} />} title={t('tours.planner.inspector.title')}>
{analysis ? (
<div className="flex flex-col gap-3">
@@ -281,20 +304,46 @@ function GpxViewRail({
)}
</RailCard>
</div>
{tour.tour_type === 'kayak' && (
<div className="flex-none border-t border-edge-faint px-3 py-3">
<PaddleSafetyRow />
</div>
)}
</section>
<NewTourConfirmDialog planner={planner} canEdit={canEdit} />
</>
)
}
/** A paddle tour's cards in the editor, after its control points: the obstacle checklist, the time and the wind. */
function PaddleEditorCards({ planner, locked, checklist }: {
planner: TourPlannerController
locked: boolean
checklist?: PaddleChecklist
}) {
if (!planner.isPaddle) return null
return (
<>
{checklist && <PaddleCheckCard checklist={checklist} />}
<PaddleTimeCard planner={planner} locked={locked} />
<PaddleWindSection placeId={planner.editingPlaceId} start={planner.waypoints[0] ?? null} />
</>
)
}
export function TourPlannerRail({ planner,
onOpenWanderer,
conversion,
recordedTrack, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController
paddleChecklist,
recordedTrack, trackPaddlePlan, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController
/** Planning an editable tour from the import in view. */
conversion?: GpxConversion
/** The way back to the recorded track of an import planned over in place. */
recordedTrack?: RecordedTrack
/** The portages and locks of a recorded paddle track in view (PAD-03). */
trackPaddlePlan?: TrackPaddlePlan
/** The obstacle checklist of the paddle tour open in the rail (PAD-06). */
paddleChecklist?: PaddleChecklist
/** Browse the user's wanderer trails instead; offered while nothing is being edited. */
onOpenWanderer?: () => void
} & TourPermissionProps) {
@@ -304,6 +353,8 @@ export function TourPlannerRail({ planner,
const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null)
const alpineAcknowledged = useRef(false)
const isSaving = planner.isSaving
// Nothing in the editor changes while the viewer may not edit or a save is in flight.
const locked = !canEdit || isSaving
const requestClose = () => {
if (isSaving) return
if (planner.hasUnsavedChanges) setConfirmDiscard(true)
@@ -318,7 +369,8 @@ export function TourPlannerRail({ planner,
return <NeutralRail planner={planner} canEdit={canEdit} onOpenWanderer={onOpenWanderer}
/>
if (planner.mode.type === 'view-gpx')
return <GpxViewRail planner={planner} canEdit={canEdit} conversion={conversion} />
return <GpxViewRail planner={planner} canEdit={canEdit} conversion={conversion} trackPaddlePlan={trackPaddlePlan}
paddleChecklist={paddleChecklist} />
const isSavedEdit = planner.mode.type === 'edit-saved'
@@ -330,30 +382,35 @@ export function TourPlannerRail({ planner,
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} aside={<RailStatusBadges planner={planner} />} />
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}>
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={locked || !planner.canUndo}>
<Undo2 size={15} strokeWidth={2} />
</BarButton>
<BarButton label={t('tours.planner.redo')} onClick={() => { if (canEdit && !isSaving) planner.redo() }} disabled={!canEdit || isSaving || !planner.canRedo}>
<BarButton label={t('tours.planner.redo')} onClick={() => { if (canEdit && !isSaving) planner.redo() }} disabled={locked || !planner.canRedo}>
<Redo2 size={15} strokeWidth={2} />
</BarButton>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
<TourNameField planner={planner} locked={!canEdit || isSaving} />
<TourNameField planner={planner} locked={locked} />
<TourTypeFields planner={planner} locked={locked} />
{recordedTrack?.available && <RecordedTrackCard recordedTrack={recordedTrack} disabled={isSaving} />}
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
<TourDetailsSection planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
<PaddleEditorCards planner={planner} locked={locked} checklist={paddleChecklist} />
<TourDetailsSection planner={planner} locked={locked} onDifficultyChange={changeDifficulty} />
</div>
<div className="flex flex-none flex-col border-t border-edge-faint px-3 py-3">
<div className="flex flex-none flex-col gap-2 border-t border-edge-faint px-3 py-3">
{planner.isPaddle && <PaddleSafetyRow />}
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}>
{t('common.discard')}
</DialogButton>
<DialogButton variant="primary" icon={<Save size={14} strokeWidth={2} />} onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={!canEdit || isSaving || !planner.canSave}>
<DialogButton variant="primary" icon={<Save size={14} strokeWidth={2} />} onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={locked || !planner.canSave}>
{t('common.save')}
</DialogButton>
</div>
@@ -518,6 +575,35 @@ interface TourPlannerToursRailProps extends TourPermissionProps {
gpxImport?: TourGpxImport
}
/** The time facts of the overview: the estimate (walking, or paddling for a paddle tour), the breaks and the planned total. */
function plannerTimeFields(planner: TourPlannerController, t: Translate) {
return [
planner.isPaddle ? {
label: t('tours.paddle.timeEstimated'),
shortLabel: t('tours.paddle.timeEstimatedShort'),
info: { label: t('tours.paddle.timeEstimatedInfoLabel'), description: t('tours.paddle.timeEstimatedInfo') },
value: planner.walkingDurationMinutes == null
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.walkingDurationMinutes),
} : {
label: t('tours.planner.walkingEstimated'),
shortLabel: t('tours.planner.walkingEstimatedShort'),
info: { label: t('tours.planner.walkingEstimatedInfoLabel'), description: t('tours.planner.walkingEstimatedInfo') },
value: planner.walkingDurationMinutes == null
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.walkingDurationMinutes),
},
{
label: t('tours.planner.breaksShort'),
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
},
{
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
},
]
}
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
gpxImport, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
@@ -528,7 +614,8 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
const isSaving = planner.isSaving
const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null
const routeAnalysis = planner.routeAnalysis
// A paddle tour's heights are read off the banks, not the water: they stay unknown.
const routeAnalysis = planner.isPaddle && planner.routeAnalysis ? withoutHeights(planner.routeAnalysis) : planner.routeAnalysis
// A finished save is said once, as the app says it everywhere: a toast.
// Once per save: the toast helper and t are fresh objects on every render, so the
@@ -561,24 +648,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
selectTour(tour)
}
const tourTimeFields = [
{
label: t('tours.planner.walkingEstimated'),
shortLabel: t('tours.planner.walkingEstimatedShort'),
info: { label: t('tours.planner.walkingEstimatedInfoLabel'), description: t('tours.planner.walkingEstimatedInfo') },
value: planner.walkingDurationMinutes == null
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.walkingDurationMinutes),
},
{
label: t('tours.planner.breaksShort'),
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
},
{
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
},
]
const tourTimeFields = plannerTimeFields(planner, t)
return (
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
@@ -603,7 +673,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
<div className="flex flex-col gap-3">
<FerrataCrossingBadges />
<RailFacts facts={tourMetricFacts(routeAnalysis, distanceUnit, t, tourTimeFields)} />
{routeAnalysis && (
{routeAnalysis && !planner.isPaddle && (
<ElevationSection
id="tour-planner-editable-elevation-profile"
samples={routeAnalysis.distanceIndexedProfileSamples}
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { TourTypeFields } from './TourTypeFields';
import type { TourPlannerController } from './useTourPlanner';
/** A planner as far as the type fields read it: a paddle tour unless told otherwise. */
function planner(overrides: Record<string, unknown> = {}, paddle: Record<string, unknown> = {}) {
const base = hikePlannerDefaults();
return {
...base,
tourType: 'kayak',
isPaddle: true,
paddle: { ...base.paddle, recommendedSpeedKmh: 4, effectiveSpeedKmh: 4, ...paddle },
...overrides,
} as unknown as TourPlannerController;
}
describe('TourTypeFields (PAD-01)', () => {
it('a hike shows the type switch only, and choosing paddle is handed to the planner', () => {
const hike = planner({ tourType: 'hike', isPaddle: false });
render(<TourTypeFields planner={hike} locked={false} />);
expect(screen.queryByTestId('tour-planner-paddle-fields')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Paddle tour/ }));
expect(hike.setTourType).toHaveBeenCalledWith('kayak');
});
it('a paddle tour asks for exactly one boat and its water, and says so until both are chosen', () => {
const paddleTour = planner({ paddleIncomplete: true });
render(<TourTypeFields planner={paddleTour} locked={false} />);
fireEvent.click(screen.getByRole('button', { name: 'Canoe' }));
fireEvent.click(screen.getByRole('button', { name: 'Lake' }));
expect(paddleTour.paddle.setBoatType).toHaveBeenCalledWith('canoe');
expect(paddleTour.paddle.setWaterContext).toHaveBeenCalledWith('lake');
// What the tour still needs speaks here; the obstacles on the water moved into the checklist card.
const notes = screen.getAllByRole('status').map((note) => note.textContent);
expect(notes).toContain('Choose the boat and the water to save this paddle tour.');
expect(notes).not.toContain('Loading the obstacles along the route …');
});
it('reads a typed speed with either decimal separator, and an empty field as the suggestion', () => {
const paddleTour = planner();
render(<TourTypeFields planner={paddleTour} locked={false} />);
const input = screen.getByPlaceholderText('4');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '4,5' } });
expect(paddleTour.paddle.setSpeedKmh).toHaveBeenLastCalledWith(4.5);
fireEvent.change(input, { target: { value: 'fast' } });
expect(paddleTour.paddle.setSpeedKmh).toHaveBeenLastCalledWith(Number.NaN);
fireEvent.change(input, { target: { value: '' } });
expect(paddleTour.paddle.setSpeedKmh).toHaveBeenLastCalledWith(null);
fireEvent.keyDown(input, { key: 'Enter' });
fireEvent.blur(input);
});
it('offers the boat suggestion beside a speed of the paddler own, taken only on purpose', () => {
const paddleTour = planner({}, { boatType: 'kayak', speedKmh: 6 });
render(<TourTypeFields planner={paddleTour} locked={false} />);
fireEvent.click(screen.getByRole('button', { name: /4 km\/h/ }));
expect(paddleTour.paddle.adoptRecommendedSpeed).toHaveBeenCalled();
});
it('names a speed that does not hold, and asks for one where the boat has no suggestion', () => {
const { unmount } = render(
<TourTypeFields planner={planner({ paddleSpeedInvalid: true }, { speedKmh: Number.NaN })} locked={false} />
);
expect(screen.getByRole('alert')).toBeInTheDocument();
unmount();
render(
<TourTypeFields
planner={planner({}, { boatType: 'other', recommendedSpeedKmh: null, effectiveSpeedKmh: null })}
locked={false}
/>
);
expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument();
});
});

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