Compare commits

...

67 Commits

Author SHA1 Message Date
Maurice e725d4f0a9 test(packing): sorted A to Z only the category grip stays draggable 2026-10-18 14:43:55 +02:00
Maurice 36f1b5b7d9 refactor(journey): read the stored dismissed suggestions as the list they are written as
The reader also took {id, dismissedAt} records, a shape nothing writes.
2026-10-18 14:07:25 +02:00
Luffy acce063dbf fix: fix journey test fail 2026-10-18 14:04:23 +02:00
Luffy d229613e0e fix(journey): add localized error messages for dismissing suggestions 2026-10-18 14:04:22 +02:00
Luffy 5622113348 fix(journey): persist dismissed suggestions in local storage 2026-10-18 14:04:22 +02:00
Maurice cd2ecf897b test(mcp): search_pois asks the index by Overture path 2026-10-18 13:58:30 +02:00
Maurice f5af9d1259 chore(deps-dev): bump source-map-js to 1.2.2 in /plugin-sdk
Replaces Dependabot's #2680, whose branch sat 983 commits behind dev.
2026-10-18 13:21:54 +02:00
Maurice cecfdf2dcc fix(packing): tell a dragged category by its data type, not by a state flag
A flag set on dragstart could still be unrendered when a quick drag reached
the next card, and the drop was lost. The category now travels in the drag
under its own type, read from the first dragover on and at the drop.
2026-10-18 13:21:54 +02:00
Maurice 23b28a1f3f feat(packing): drag a category by its head to move it among the others
Categories showed in the order they were created, which a list kept per day
cannot undo once the days swap. A grip in each category's head band moves
the whole category onto another's place on desktop. A category has no
position of its own, it shows where its first item is, so the move reorders
the items as one block through the item reorder the list already has: no
new column, and the order reaches everyone the way a dragged item does. The
other view's categories keep their order.
2026-10-18 13:21:54 +02:00
Maurice f7f6b83d33 refactor(journey,docsync): keep both services inside their size baseline
The time placement of a journey entry moves whole into journey-entry-order,
and a moved entry only gets a sort_order when it actually moves, so a patch
never carries an undefined one. The Synology login comment shrinks to a line.
2026-10-18 13:21:53 +02:00
Maurice 097c1da0af fix(mobile): a transport card shows the notes of its booking
On the phone the Transports tab never rendered a booking's notes, so what
someone typed into a flight was only to be found by opening its sheet,
while the booking cards next to it showed theirs. Both cards now take the
notes from one part, sized on the caption tier so the text size setting
reaches them.
2026-10-18 13:21:53 +02:00
Maurice a74a2cf9b5 fix(map): the day route waits out a busy router instead of keeping straight lines
The public OSRM hosts answer 429 above about one request a second. The trip
overview and the road trip waited that out and asked again; the day route
took the first refusal as final and drew straight lines between the stops
until the next edit, which is what a day with a change of mode or a quick
switch between days ran into. Both the day route and the overview now go
through one retry helper: a rate limit is waited out, as long as the host
asks when it says, and a refusal the host meant still ends at once.
2026-10-18 13:21:52 +02:00
Maurice dc1ff06573 fix(docsync): Synology login sends plain credentials, a refused test shows its reason
The login went to auth.cgi with account and password JSON-quoted, the way
the FileStation calls on entry.cgi take their values. entry.cgi reads JSON
where it can; auth.cgi takes a value as it comes, so DSM looked for a user
named "trek" with the quotes and refused every account. Both go plain now.

The test verdict sat truncated next to the dialog buttons ("The credentials
were re..."), with nothing to read the rest by. It wraps to two lines and
shows the store's own reason on hover; the phone sheet prints it below.

Closes #2666
2026-10-18 13:21:52 +02:00
Maurice fc631d36b6 fix(maps): category pills ask the index by Overture path, near misses are left out
The index matches a term anywhere inside a place's category text, so the
bare names behind the pills caught whatever was spelled like them: "park"
brought car parks and an amusement park, "garden" furniture stores and beer
gardens, "bar" barbers, "pub" publishers and public squares, "market"
marketing agencies. In Vienna four of sixty "Nature & parks" pins were
parks. Every pill now names its Overture categories by full path, a place
counts only when its path is that path or lies under it, and a page of
near misses only falls through to Overpass. Terms Overture no longer has
(nightclub, nature_preserve, shopping_center ...) got their current names.

Closes #2663
2026-10-18 13:21:52 +02:00
Maurice 6463060f67 chore(deps): bump concurrently to 10.0.6 and shell-quote to 1.12.0
Replaces Dependabot's #2658, closed because its security rebase kept
bouncing between main and dev. Only the two packages move in the lockfile.
2026-10-18 13:21:51 +02:00
Maurice 593f839592 fix(journey): a timed entry takes its place in the day by its time
A new entry always went to the end of its day, so writing up 1 am after
2 am left the night out of order, and changing an entry's time never moved
it. A timed entry now lands before the first entry of its day that starts
later, on create and when its time or day changes; untimed entries still
go last, and the hand-made order of the others stays as it was. A save
that leaves time and day untouched keeps an entry where it was dragged.

Closes #2679
2026-10-18 13:21:51 +02:00
Maurice a9c5cf6d9a fix(share): a stop's stay comes from its time window, not the one-hour place default
The public page showed places.duration_minutes under every stop, and that is
60 on every place nobody set a road-trip stay for, so a shared plan read "1 h"
everywhere. The stay is now the stop's start to end time, per visit, past
midnight included, and a stop without an end shows none.

Closes #2676
2026-10-18 13:21:51 +02:00
Maurice f901982903 fix(llm-parse): LLM_MAX_OUTPUT_TOKENS sets the answer cap, a cut-off answer says so
The OpenAI-compatible client always asked for 4096 tokens, and a document
whose bookings needed more came back as "no reservations" with nothing that
pointed at the cap. LLM_MAX_OUTPUT_TOKENS now sets the cap for every
provider; unset keeps 4096 and 8192 so a small local model's context still
fits. A finish_reason of "length" is reported like Anthropic's max_tokens
stop, naming the limit and the variable that raises it.

Closes #2677
2026-10-18 13:21:50 +02:00
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
Maurice 52ed63715c Tours: tests follow the provenance read and the shared day assignment, the tours addon ends MCP sessions, a line without a point is not copied
The repository stubs of the tour service tests answer findProvenance, which getTour and createTour read since P2C-02.
Switching the Tours addon off now ends live MCP sessions like every other addon that gates a tool (MCP-GATE-PARITY-001).
copyTour refuses a line without a point instead of placing the copy at 0/0, and new tests cover the reachable paths
of the copy, the source check and the provenance of a snapshot without its import record, including a trip copy
keeping that record. On the client the shared day assignment hands the plan exactly the tour and the day, the new
visibility store is reset between tests, and the tests of the sidebar, the detail, the phone sheet and the origin
section match the views this PR built.
2026-10-15 22:34:33 +00:00
Maurice 1d7758b819 Tours: the trip copy keeps its copied tours in one map, so trips.service stays at its size baseline; unused TourMapVisibility type removed 2026-10-15 21:40:19 +00:00
Maurice b33727ec75 Tours: no non-null assertion, useless escape or awaited loop in the tour and trip copy paths 2026-10-15 23:25:16 +02:00
Maurice 97b5f36ef0 Tours: the list filter names a tour planned here TREK, as its badge does 2026-10-15 23:11:39 +02:00
Maurice 845979902c Tours: client tests for the new hooks and views, row chips fade at their end, admin user table aligned; strict and lint ratchets green 2026-10-15 23:08:46 +02:00
Maurice a0884685ef Map: tracks drawn from their own modules on both maps; mobile filter chips on the type scale 2026-10-15 22:25:34 +02:00
Maurice 485e360c64 Tours: save a tour as a new, independent tour (P2C-07) 2026-10-15 22:18:41 +02:00
Maurice 356df90095 Tours: read the saved tours of a trip over MCP (P2D-MCP-01) 2026-10-15 22:08:39 +02:00
Maurice cd1b1410bd Tours: filter a tour list by source, routing limit and type where it varies (PR1-13) 2026-10-15 22:02:41 +02:00
Maurice 3d9095ccde Tours: provenance contract, source check, one assignment path and the map overview
- P2C-02: tours record their origin (planned, GPX with file name, wanderer)
  instead of reading it off their shape; the detail answers a provenance
  contract and a trip copy keeps a tour's import record
- MF-03: a wanderer snapshot's source can be checked on purpose, read only,
  with a confirmed reimport as a new snapshot
- PR1-18: every way of putting a tour on a day shares one set of rules
- PR1-07/08: each person can hide tours from the trip map; the list shows
  each tour's line colour and the picked tour stands out on both maps
2026-10-15 21:51:22 +02:00
Maurice 018d2d1425 Tours/wanderer: cover the new service, repository and restore paths
- unit suites for WandererService (every refusal and fallback), the
  wanderer connection and tour source repositories, and restoring a
  recorded track (GPX and wanderer imports, empty or missing track, 404s)
- client cases for a body that breaks off and the trail count fallbacks
- an import whose realtime notification fails still stands (e2e)
- the search controller passes the validated body on; the schema already
  defaults q and page, so the fallbacks there could never run
2026-10-15 20:15:17 +02:00
Maurice cab742e641 Tours: bring the pinned counts and test doubles up to the new tables and switch
- tours locale key count, entity count and NOT NULL field count follow
  the wanderer tables and strings
- the shared MSW admin handlers answer /api/admin/tours-wanderer
- the mobile tour detail test checks the dialog, not the dropped heading
- the refused-origin e2e sends one request per case instead of four at once
2026-10-15 19:25:22 +02:00
Maurice 11f23997e8 Tours: copy a planned tour's import link in the repository, and teach the tests the new columns 2026-10-15 18:38:44 +02:00
Maurice f1c437b8b8 Keep api/client.ts and trips.service.ts within their size baselines
The admin switch for wanderer moves to api/wanderer.ts and the recorded
track restore to api/recordedTrack.ts; trips.service.ts keeps dev's
formatting with only the derived-from remap on copy.
2026-10-15 18:16:25 +02:00
Maurice f690065de8 Tours: resolve the distance unit once where tour facts are built 2026-10-15 17:51:19 +02:00
Maurice 782e613506 Tours: day GPX import, wanderer trails and planning over an import
- GPX files drop onto the map or a day; the import lands on that day
- wanderer connection per user in Integrations; the workspace offers the
  user's own trails only, behind an admin switch under the Tours addon
- wanderer difficulty is stored on the SAC scale (T1 to T3), backfilled
- a GPX or wanderer import can be planned as an editable tour, either in
  place (the recorded track is kept and can be restored) or as a new tour
  that remembers the import it came from
- rails, rows and detail dialog follow the plan sidebar's cards and badges
- admin addon shelves with more than one row fold, collapsed by default
- reverse-geocoded waypoint names are cached on the device
2026-10-15 17:44:15 +02:00
Maurice 31ffcf1228 fix(ferratas): sort route tile keys with localeCompare 2026-10-15 12:09:48 +02:00
673 changed files with 60079 additions and 2111 deletions
+6 -6
View File
@@ -1,25 +1,25 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 876,
"@typescript-eslint/no-misused-promises": 875,
"no-console": 88,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 148,
"@typescript-eslint/no-explicit-any": 147,
"trek/no-swallowed-catch": 133,
"max-params": 35,
"trek/store-without-selector": 69,
"react-hooks/refs": 561,
"react-hooks/refs": 559,
"react-hooks/set-state-in-effect": 197,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121,
"@typescript-eslint/no-unused-vars": 155,
"@typescript-eslint/no-unused-vars": 130,
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 15,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 42,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 46,
"unused-disable-directive": 6,
"max-depth": 21,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1949,
"@typescript-eslint/no-non-null-assertion": 1946,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+6 -1
View File
@@ -717,12 +717,17 @@
"hooks": ["src/components/Tours/tourPresentation.ts"],
"desktop": [
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/ToursSidebar.tsx",
"src/components/Tours/planner/TourPlannerPanels.tsx",
"src/components/Tours/tourParts.tsx"
],
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
},
{
"feature": "Tours list facets",
"hooks": ["src/components/Tours/useTourListFilters.ts"],
"desktop": ["src/components/Tours/ToursSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/places/MPlacesBrowser.tsx"]
},
{
"feature": "Transport booking detail view: reservationFiles",
"hooks": ["src/utils/reservationFiles.ts"],
-1
View File
@@ -1028,7 +1028,6 @@
"src/components/Todo/useTodoList.ts",
"src/components/Tours/PlacesToursModeSwitch.tsx",
"src/components/Tours/TourDetailDialog.test.tsx",
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/TourListRow.tsx",
"src/components/Tours/ToursSidebar.test.tsx",
"src/components/Tours/ToursSidebar.tsx",
-1
View File
@@ -130,7 +130,6 @@
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
"src/pages/JourneyDetailPage.tsx",
"src/pages/OAuthAuthorizePage.tsx",
"src/pages/TripPlannerPage.tsx",
"src/pages/admin/AdminSettingsTab.tsx",
"src/pages/admin/useAdmin.ts",
"src/pages/admin/useNotificationDefaults.ts",
+4 -4
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": 1450,
"src/components/Map/MapView.tsx": 1409,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2414,
"src/components/Map/MapViewGL.tsx": 2299,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
@@ -24,7 +24,7 @@
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1153,
"src/pages/TripPlannerPage.tsx": 1142,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
-1
View File
@@ -89,7 +89,6 @@
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
"src/components/Tours/tourParts.tsx": 8,
"src/components/Trips/TripFormModal.tsx": 3,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/Vacay/VacayCalendar.tsx": 1,
+2 -2
View File
@@ -62,7 +62,7 @@
"components/Map/LocationButton.tsx": 1,
"components/Map/MapPluginMarkers.tsx": 2,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 10,
"components/Map/MapView.tsx": 8,
"components/Map/MapViewGL.tsx": 11,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
@@ -269,7 +269,7 @@
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
+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)),
};
+8
View File
@@ -0,0 +1,8 @@
import type { TourDetailResponse } from '@trek/shared';
import apiClient from './client';
/** An import that was planned over in place keeps its recorded track; this brings it back. */
export const recordedTrackApi = {
restore: (tripId: number | string, placeId: number | string): Promise<TourDetailResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/restore-recorded`).then((r) => r.data),
};
+8
View File
@@ -0,0 +1,8 @@
import type { TourCopyRequest, TourCreateResponse } from '@trek/shared';
import apiClient from './client';
/** P2C-07: a tour of the trip saved again as a new, independent tour. */
export const tourCopiesApi = {
copy: (tripId: number | string, placeId: number | string, body: TourCopyRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/copy`, body).then((r) => r.data),
};
+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),
};
+53
View File
@@ -0,0 +1,53 @@
import type {
AdminFeatureToggleResponse,
TourSourceCheckResponse,
WandererConnection,
WandererImportPreview,
WandererImportRequest,
WandererImportResponse,
WandererSaveResponse,
WandererSearchRequest,
WandererSearchResponse,
WandererSettingsRequest,
WandererTestRequest,
WandererTestResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* How long the browser waits for a call that makes the server ask wanderer.
* A preview reads the trail's index entry, its record and its GPX one after
* another, each allowed 15 s on the server, so the shared 8 s would give up
* while the server carries on.
*/
const WANDERER_UPSTREAM_TIMEOUT_MS = 60_000;
const upstream = { timeout: WANDERER_UPSTREAM_TIMEOUT_MS };
export const wandererApi = {
getSettings: (): Promise<WandererConnection> => apiClient.get('/integrations/wanderer/settings').then((r) => r.data),
saveSettings: (data: WandererSettingsRequest): Promise<WandererSaveResponse> =>
apiClient.put('/integrations/wanderer/settings', data).then((r) => r.data),
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
preview: (tripId: number, trailId: string, signal?: AbortSignal): Promise<WandererImportPreview> =>
apiClient
.get(`/trips/${tripId}/tours/wanderer/trails/${encodeURIComponent(trailId)}`, { ...upstream, signal })
.then((r) => r.data),
importTrail: (tripId: number, body: WandererImportRequest): Promise<WandererImportResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/import`, body, upstream).then((r) => r.data),
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
};
/** The admin's switch for wanderer under the Tours addon. */
export const wandererAdminApi = {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get('/admin/tours-wanderer').then((r) => r.data),
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put('/admin/tours-wanderer', { enabled }).then((r) => r.data),
};
+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)),
};
@@ -1,4 +1,3 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-039
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
@@ -56,6 +55,12 @@ function isOn(toggle: HTMLElement): boolean {
return toggle.getAttribute('aria-pressed') === 'true';
}
/** A shelf of more than one row starts folded; open every one so its rows can be read. */
async function unfoldShelves(): Promise<void> {
const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false });
for (const bar of bars) await userEvent.click(bar);
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -72,7 +77,8 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { dark_mode: false } });
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
);
});
@@ -228,6 +234,7 @@ describe('AddonManager', () => {
)
);
render(<AddonManager />);
await unfoldShelves();
// Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash');
@@ -293,6 +300,7 @@ describe('AddonManager', () => {
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
@@ -427,6 +435,8 @@ describe('AddonManager', () => {
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Paperless-ngx');
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
@@ -524,6 +534,8 @@ describe('AddonManager', () => {
/>,
);
await unfoldShelves();
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
expect(screen.getByText('Polls')).toBeInTheDocument();
@@ -551,8 +563,9 @@ describe('AddonManager', () => {
<AddonManager
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
onToggleCollabFeature={vi.fn()}
/>,
/>
);
await unfoldShelves();
const links = await screen.findByText('Links');
expect(links).toHaveAttribute('title');
@@ -839,4 +852,89 @@ describe('AddonManager', () => {
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: false, links: true, polls: false, whatsnext: false }}
onToggleCollabFeature={vi.fn()}
/>
);
const bar = await screen.findByRole('button', { name: /5 options/, expanded: false });
expect(bar).toHaveTextContent('2 on');
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
await userEvent.click(bar);
expect(bar).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Chat')).toBeInTheDocument();
await userEvent.click(bar);
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => {
server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Lists', icon: 'ListChecks', enabled: true })]));
render(<AddonManager bagTrackingEnabled onToggleBagTracking={vi.fn()} />);
expect(await screen.findByRole('button', { name: 'Bag Tracking' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-042: wanderer hangs off Tours and saves its switch', async () => {
const puts: unknown[] = [];
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.put('/api/admin/tours-wanderer', async ({ request }) => {
puts.push(await request.json());
return HttpResponse.json({ enabled: true });
})
);
render(
<>
<AddonManager />
<ToastContainer />
</>
);
const toggle = await screen.findByRole('button', { name: 'wanderer' });
expect(isOn(toggle)).toBe(false);
await userEvent.click(toggle);
await waitFor(() => expect(puts).toEqual([{ enabled: true }]));
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
});
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.put('/api/admin/tours-wanderer', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
);
render(
<>
<AddonManager />
<ToastContainer />
</>
);
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
});
it('FE-ADMIN-ADDON-044: Tours and Road trip show their own icons, not the fallback puzzle', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: false }),
buildAddon({ id: 'roadtrip', name: 'Road trip', icon: 'Route', enabled: false }),
])
);
render(<AddonManager />);
const tours = (await screen.findByText('Tours')).closest('article') as HTMLElement;
const roadtrip = screen.getByText('Road trip').closest('article') as HTMLElement;
expect(tours.querySelector('svg.lucide-mountain')).toBeInTheDocument();
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
});
});
+18 -1
View File
@@ -1,7 +1,7 @@
import { useId, type ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
import EmptyState from '../shared/EmptyState'
@@ -18,12 +18,16 @@ import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, g
import { useAddonManager } from './addons/useAddonManager'
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
import WandererMark from '../Tours/wanderer/WandererMark'
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
// used to land on the fallback.
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
Mountain,
// Road trip was seeded as 'Route'; the car says what it is better than a line does.
Route: CarFront,
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
// providers below do. Keyed by the string in the addons table.
Dawarich: DawarichIcon,
@@ -71,6 +75,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
} = useAddonManager()
if (loading) {
@@ -147,6 +153,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
if (addon.id === 'tours' && addon.enabled) {
return (
<AddonSubRow
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
)
}
// The AI-parsing settings hang off their tile like every other sub-feature,
// just as a form instead of toggle rows.
if (addon.id === 'llm_parsing' && addon.enabled) {
+50 -3
View File
@@ -1,6 +1,13 @@
import type { ReactNode } from 'react'
import { Children, type ReactNode, isValidElement, useId, useState } from 'react'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { fs } from '../shared/DialogShell'
import { ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
/** How many of the shelf's rows are switched on, read from the rows' own `enabled`. */
function enabledRows(rows: ReactNode[]): number {
return rows.filter((row) => isValidElement<{ enabled?: boolean }>(row) && row.props.enabled === true).length
}
/**
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
@@ -14,6 +21,10 @@ import { fs } from '../shared/DialogShell'
*
* The row is not a click target, only the switches are. No lift, no pointer
* cursor, no `role="button"`.
*
* A shelf of more than one row starts folded behind a bar that says how many
* options there are and how many are on, so a page of addons stays short and
* the options are still plain to see.
*/
export default function AddonTile({
icon,
@@ -31,6 +42,13 @@ export default function AddonTile({
/** The sub-shelf: `<AddonSubRow>` children. */
children?: ReactNode
}) {
const { t } = useTranslation()
const shelfId = useId()
const [open, setOpen] = useState(false)
const rows = Children.toArray(children)
const foldable = rows.length > 1
const active = enabledRows(rows)
return (
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
<div className="flex items-start gap-3">
@@ -62,11 +80,40 @@ export default function AddonTile({
</div>
</div>
{children && (
{rows.length > 0 && (
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
{foldable && (
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={shelfId}
className={`flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent ${open ? 'border-b border-edge-faint' : ''}`}
>
{/* The same slot and type as a row below, so the bar reads as one more row. */}
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<ChevronDown
size={15}
strokeWidth={2.2}
className={`transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.subOptions', { count: rows.length })}
</span>
<span
className={`flex-none rounded-full px-2 py-0.5 font-semibold ${active > 0 ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}
style={fs(10.5, 'body')}
>
{t('admin.addons.subOptionsOn', { count: active })}
</span>
</button>
)}
{/* Sitting inside the parent on its own fill is enough to say these belong
to it: no rail, no indent guides. */}
<ul className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
{(!foldable || open) && (
<ul id={shelfId} className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
)}
</div>
)}
</article>
@@ -1,6 +1,7 @@
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useToast } from '../../shared/Toast';
@@ -18,6 +19,7 @@ export function useAddonManager() {
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
const loadAddons = async () => {
try {
@@ -30,6 +32,10 @@ export function useAddonManager() {
const loadOnMount = useEffectEvent(() => {
void loadAddons().finally(() => setLoading(false));
wandererAdminApi
.get()
.then((flag) => setToursWanderer(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
});
useEffect(() => {
loadOnMount();
@@ -57,6 +63,21 @@ export function useAddonManager() {
toast.success(t('admin.addons.toast.updated'));
};
/** wanderer under Tours: flipped at once, put back when the save fails. */
const toggleToursWanderer = async () => {
const next = !toursWanderer;
setToursWanderer(next);
try {
await wandererAdminApi.update(next);
} catch {
setToursWanderer(!next);
toast.error(t('admin.addons.toast.error'));
return;
}
void refreshGlobalAddons();
toast.success(t('admin.addons.toast.updated'));
};
const asOption = (provider: Addon): ProviderOption => ({
key: provider.id,
label: provider.name,
@@ -83,5 +104,7 @@ export function useAddonManager() {
integrationAddons,
providerOptions,
documentProviderOptions,
toursWanderer,
toggleToursWanderer,
};
}
@@ -0,0 +1,320 @@
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
// controlled usePaddleRegionsAdmin (the hook has its own test).
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { isReading, progressPercent, regionStatusTone } from './paddleRegionsModel';
import PaddleRegionsPanel from './PaddleRegionsPanel';
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
const hook = vi.hoisted(() => ({ usePaddleRegionsAdmin: vi.fn<() => Admin>() }));
vi.mock('./usePaddleRegionsAdmin', () => ({ usePaddleRegionsAdmin: hook.usePaddleRegionsAdmin }));
const HINT = 'Weirs, locks and paddling rules from OpenStreetMap, read per region and again every month.';
const ATTRIBUTION =
'Data © OpenStreetMap contributors (ODbL), regional extracts by Geofabrik. Read again on the 3rd of every month.';
const EMPTY = 'No region yet. Paddle tours then show that no obstacle data is known.';
const CATALOG_DOWN = 'Geofabrik cannot be reached right now. Try again later.';
/** A region read once, its data current to the 3rd of October (midday, so every time zone keeps the day). */
function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
return {
id: 'oberpfalz',
name: 'Oberpfalz',
status: 'ready',
source_timestamp: '2026-10-03T12:00:00Z',
imported_at: '2026-10-04T08:00:00Z',
feature_count: 1234,
error: null,
progress: null,
...overrides,
};
}
/** A region before its first read: no data and no date yet. */
const unread = (overrides: Partial<PaddleRegion>): PaddleRegion =>
region({ source_timestamp: null, imported_at: null, feature_count: 0, ...overrides });
const READY = region();
const WAITING = unread({ id: 'donau', name: 'Donau', status: 'queued' });
const FAILED_AGAIN = region({ id: 'saale', name: 'Saale', status: 'failed', error: 'Download broke off.' });
const FAILED_FIRST = unread({ id: 'mosel', name: 'Mosel', status: 'failed', error: 'Not a PBF file.' });
/** Geofabrik's answers for "Limburg": the plain names told apart by the regions they lie in. */
const HITS: PaddleRegionCatalogEntry[] = [
{ id: 'germany/hessen/limburg-weilburg', name: 'Limburg-Weilburg', path: ['Europe', 'Germany', 'Hessen'] },
{ id: 'netherlands/limburg', name: 'Limburg', path: ['Europe', 'Netherlands'] },
{ id: 'belgium/limburg', name: 'Limburg', path: ['Europe', 'Belgium'] },
{ id: 'limburg', name: 'Limburg', path: [] },
];
function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['search']> = {}): Admin {
return {
toursOn: true,
offline: false,
regions: [],
loading: false,
error: null,
retry: vi.fn(),
added: new Set<string>(),
busyId: null,
add: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
removing: null,
askRemove: vi.fn(),
cancelRemove: vi.fn(),
confirmRemove: vi.fn(),
...overrides,
search: { query: '', setQuery: vi.fn(), hits: null, searching: false, error: null, ...search },
};
}
function show(overrides: Partial<Admin> = {}, search: Partial<Admin['search']> = {}) {
const state = adminState(overrides, search);
hook.usePaddleRegionsAdmin.mockReturnValue(state);
return { state, ...render(<PaddleRegionsPanel />) };
}
/** The card's title row, where the number of regions sits beside the title. */
function titleRow(): HTMLElement {
const row = screen.getByRole('heading', { name: 'Paddle water data' }).parentElement;
if (!row) throw new Error('no title row');
return row;
}
function rowOf(name: string): HTMLElement {
const row = screen.getByText(name).closest<HTMLElement>('[data-testid="paddle-region-row"]');
if (!row) throw new Error(`no row for ${name}`);
return row;
}
describe('PaddleRegionsPanel', () => {
it('FE-ADMIN-PADDLEREG-001: renders nothing while the Tours addon is off', () => {
const { container } = show({ toursOn: false, regions: [READY] });
expect(container).toBeEmptyDOMElement();
});
it('FE-ADMIN-PADDLEREG-002: shows the card with its hint, the attribution and the empty state', () => {
show();
expect(screen.getByRole('heading', { name: 'Paddle water data' })).toBeInTheDocument();
expect(screen.getByText(HINT)).toBeInTheDocument();
expect(screen.getByText(EMPTY)).toBeInTheDocument();
expect(screen.getByText(ATTRIBUTION)).toBeInTheDocument();
// No count beside the title while there is no region.
expect(titleRow()).toHaveTextContent(/^Paddle water data$/);
const search = screen.getByRole('searchbox', { name: 'Add a region' });
expect(search).toBeEnabled();
expect(search).toHaveAttribute('placeholder', 'Search Geofabrik, for example Oberpfalz');
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-region-row')).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-003: says the list is loading instead of calling it empty', () => {
show({ loading: true });
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-004: offline it says why and locks the search and every row, without the list error', () => {
show({ offline: true, error: 'Network Error', regions: [READY] });
expect(screen.getByRole('status')).toHaveTextContent('Regions can only be managed with a connection.');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByText('Network Error')).not.toBeInTheDocument();
expect(screen.getByRole('searchbox', { name: 'Add a region' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Read again Oberpfalz' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Remove region Oberpfalz' })).toBeDisabled();
});
it('FE-ADMIN-PADDLEREG-005: a failed list read shows its error with a retry that asks the hook again', () => {
const { state } = show({ error: 'Server unreachable' });
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Server unreachable');
fireEvent.click(within(alert).getByRole('button', { name: 'Try again' }));
expect(state.retry).toHaveBeenCalledTimes(1);
});
it('FE-ADMIN-PADDLEREG-006: the search shows the query and hands every change to the hook', () => {
const { state } = show({}, { query: 'Lim' });
const search = screen.getByRole('searchbox', { name: 'Add a region' });
expect(search).toHaveValue('Lim');
fireEvent.change(search, { target: { value: 'Limb' } });
expect(state.search.setQuery).toHaveBeenCalledWith('Limb');
});
it('FE-ADMIN-PADDLEREG-007: while Geofabrik is searched it says so and shows no verdict yet', () => {
show({}, { query: 'Limburg', searching: true });
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
expect(screen.queryByText('No region by that name')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-region-hit')).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-008: a failed search shows its error under the field, without the list retry', () => {
show({}, { query: 'Limburg', error: CATALOG_DOWN });
expect(screen.getByRole('alert')).toHaveTextContent(CATALOG_DOWN);
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-009: a search without hits says no region has that name', () => {
show({}, { query: 'Atlantis', hits: [] });
expect(screen.getByText('No region by that name')).toBeInTheDocument();
expect(screen.queryByTestId('paddle-region-hit')).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-010: hits name the regions they lie in, outermost first, and mark the ones added', () => {
show({ added: new Set(['germany/hessen/limburg-weilburg']) }, { query: 'Limburg', hits: HITS });
const [weilburg, dutch, belgian, bare] = screen.getAllByTestId('paddle-region-hit');
const path = within(weilburg).getAllByText(/^(Europe|Germany|Hessen)$/);
expect(path.map((part) => part.textContent)).toEqual(['Europe', 'Germany', 'Hessen']);
expect(within(weilburg).getByText('Added')).toBeInTheDocument();
expect(within(weilburg).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
expect(within(dutch).getByText('Netherlands')).toBeInTheDocument();
expect(within(dutch).getByRole('button', { name: 'Add' })).toBeEnabled();
expect(within(belgian).getByText('Belgium')).toBeInTheDocument();
// A region at the top of Geofabrik's tree has nothing above it to name.
expect(within(bare).getByText('Limburg').nextElementSibling).toBeNull();
});
it('FE-ADMIN-PADDLEREG-011: adding a hit hands its Geofabrik id to the hook', () => {
const { state } = show({}, { query: 'Limburg', hits: HITS });
const dutch = screen.getAllByTestId('paddle-region-hit')[1];
fireEvent.click(within(dutch).getByRole('button', { name: 'Add' }));
expect(state.add).toHaveBeenCalledWith('netherlands/limburg');
});
it('FE-ADMIN-PADDLEREG-012: while one region is added every add waits, and the one under way spins', () => {
show({ busyId: 'netherlands/limburg' }, { query: 'Limburg', hits: HITS });
const [, dutch, belgian] = screen.getAllByTestId('paddle-region-hit');
const adding = within(dutch).getByRole('button', { name: 'Add' });
const waiting = within(belgian).getByRole('button', { name: 'Add' });
expect(adding).toBeDisabled();
expect(waiting).toBeDisabled();
expect(adding.querySelector('.animate-spin')).not.toBeNull();
expect(waiting.querySelector('.animate-spin')).toBeNull();
});
it('FE-ADMIN-PADDLEREG-013: a waiting region reads Waiting, has no data yet and cannot be read again', () => {
show({ regions: [WAITING] });
const row = rowOf('Donau');
expect(within(row).getByText('Waiting')).toHaveClass('border-edge-faint');
expect(within(row).queryByText(/objects?$/)).not.toBeInTheDocument();
expect(within(row).queryByText(/^As of/)).not.toBeInTheDocument();
expect(within(row).getByRole('button', { name: 'Read again Donau' })).toBeDisabled();
expect(within(row).getByRole('button', { name: 'Remove region Donau' })).toBeEnabled();
});
it('FE-ADMIN-PADDLEREG-014: a region being read shows its phase and share, the phase alone while the size is unknown', () => {
show({
regions: [
unread({ id: 'elbe', name: 'Elbe', status: 'importing', progress: { phase: 'download', fraction: 0.416 } }),
unread({ id: 'main', name: 'Main', status: 'importing', progress: { phase: 'read', fraction: 0 } }),
unread({ id: 'isar', name: 'Isar', status: 'importing', progress: { phase: 'download', fraction: null } }),
unread({ id: 'naab', name: 'Naab', status: 'importing' }),
],
});
const elbe = rowOf('Elbe');
expect(within(elbe).getByText('Downloading 42%')).toHaveClass('bg-accent');
expect(within(elbe).getByRole('button', { name: 'Read again Elbe' })).toBeDisabled();
expect(within(elbe).getByRole('button', { name: 'Remove region Elbe' })).toBeEnabled();
// A read just begun says 0 %, not nothing.
expect(within(rowOf('Main')).getByText('Reading 0%')).toBeInTheDocument();
expect(within(rowOf('Isar')).getByText('Downloading')).toBeInTheDocument();
expect(within(rowOf('Naab')).getByText('Reading')).toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-015: a ready region shows its object count and the date its data is current to', () => {
show({ regions: [READY, region({ id: 'regen', name: 'Regen', feature_count: 1, source_timestamp: null })] });
const row = rowOf('Oberpfalz');
expect(within(row).getByText('Ready')).toHaveClass('bg-success-soft');
expect(within(row).getByText('1234 objects')).toBeInTheDocument();
expect(within(row).getByText('As of Oct 3, 2026')).toBeInTheDocument();
expect(within(row).getByRole('button', { name: 'Read again Oberpfalz' })).toBeEnabled();
// One object reads singular; without a source date there is no date to show.
const regen = rowOf('Regen');
expect(within(regen).getByText('1 object')).toBeInTheDocument();
expect(within(regen).queryByText(/^As of/)).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-016: a failed read shows its error, and that the earlier data stays in use when there is some', () => {
show({ regions: [FAILED_AGAIN, FAILED_FIRST] });
const again = rowOf('Saale');
expect(within(again).getByText('Failed')).toHaveClass('bg-danger-soft');
const error = within(again).getByText('Download broke off. The data read before stays in use.');
expect(error).toHaveAttribute('title', 'Download broke off.');
const first = rowOf('Mosel');
expect(within(first).getByText('Not a PBF file.')).toBeInTheDocument();
expect(within(first).queryByText(/stays in use/)).not.toBeInTheDocument();
expect(within(first).queryByText(/objects?$/)).not.toBeInTheDocument();
expect(within(first).getByRole('button', { name: 'Read again Mosel' })).toBeEnabled();
});
it('FE-ADMIN-PADDLEREG-017: Read again and Remove hand the region to the hook', () => {
const { state } = show({ regions: [READY] });
fireEvent.click(screen.getByRole('button', { name: 'Read again Oberpfalz' }));
expect(state.refresh).toHaveBeenCalledWith('oberpfalz');
fireEvent.click(screen.getByRole('button', { name: 'Remove region Oberpfalz' }));
expect(state.askRemove).toHaveBeenCalledWith(READY);
// The question opens only once the hook holds the region to remove.
expect(screen.queryByRole('heading', { name: 'Remove region' })).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-018: while one region is busy its row dims and no row can be read again or removed', () => {
show({ regions: [READY, FAILED_AGAIN], busyId: 'oberpfalz' });
expect(rowOf('Oberpfalz')).toHaveClass('opacity-60');
expect(rowOf('Saale')).not.toHaveClass('opacity-60');
for (const name of ['Oberpfalz', 'Saale']) {
expect(screen.getByRole('button', { name: `Read again ${name}` })).toBeDisabled();
expect(screen.getByRole('button', { name: `Remove region ${name}` })).toBeDisabled();
}
});
it('FE-ADMIN-PADDLEREG-019: the title carries the number of regions', () => {
show({ regions: [READY, WAITING, FAILED_FIRST] });
expect(within(titleRow()).getByText('3')).toBeInTheDocument();
expect(screen.getAllByTestId('paddle-region-row')).toHaveLength(3);
});
it('FE-ADMIN-PADDLEREG-020: removing asks first, naming the region, and confirming hands it to the hook', () => {
const { state } = show({ regions: [READY], removing: READY });
const message = 'Oberpfalz and its obstacle data are removed. Paddle tours there then show no data.';
expect(screen.getByRole('heading', { name: 'Remove region' })).toBeInTheDocument();
expect(screen.getByText(message)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
expect(state.confirmRemove).toHaveBeenCalledTimes(1);
});
it('FE-ADMIN-PADDLEREG-021: Cancel and Escape take the question back without removing', () => {
const { state } = show({ regions: [READY], removing: READY });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
fireEvent.keyDown(document, { key: 'Escape' });
expect(state.cancelRemove).toHaveBeenCalledTimes(2);
expect(state.confirmRemove).not.toHaveBeenCalled();
});
});
describe('paddleRegionsModel', () => {
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
expect(isReading({ status: 'queued' })).toBe(true);
expect(isReading({ status: 'importing' })).toBe(true);
expect(isReading({ status: 'ready' })).toBe(false);
expect(isReading({ status: 'failed' })).toBe(false);
});
it('FE-ADMIN-PADDLEREG-MODEL-002: the share of a read is a whole percent, null while its size is unknown', () => {
expect(progressPercent({ progress: null })).toBeNull();
expect(progressPercent({ progress: { phase: 'download', fraction: null } })).toBeNull();
expect(progressPercent({ progress: { phase: 'download', fraction: 0 } })).toBe(0);
expect(progressPercent({ progress: { phase: 'read', fraction: 0.416 } })).toBe(42);
expect(progressPercent({ progress: { phase: 'read', fraction: 1 } })).toBe(100);
});
it('FE-ADMIN-PADDLEREG-MODEL-003: the badge tone follows the state of the read, never the water', () => {
expect(regionStatusTone('queued')).toBe('neutral');
expect(regionStatusTone('importing')).toBe('accent');
expect(regionStatusTone('ready')).toBe('success');
expect(regionStatusTone('failed')).toBe('danger');
});
});
@@ -0,0 +1,278 @@
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { Check, ChevronRight, Loader2, Plus, RefreshCw, Search, Trash2, Waves, WifiOff } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import {
SettingRows,
SETTINGS_BUTTON,
SETTINGS_ICON_BUTTON,
SettingsCard,
SettingsHint,
StatusPill,
} from '../../Settings/settingsKit';
import ConfirmDialog from '../../shared/ConfirmDialog';
import { EditorField, INPUT } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import ErrorBoundary from '../../shared/ErrorBoundary';
import { Tooltip } from '../../shared/Tooltip';
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
/**
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
* picked from Geofabrik's list. Both the desktop and the phone admin show
* this one card under the addons, while the Tours addon is on.
*/
export default function PaddleRegionsPanel() {
return (
<ErrorBoundary boundaryId="paddle-regions">
<Panel />
</ErrorBoundary>
);
}
function Panel() {
const { t } = useTranslation();
const admin = usePaddleRegionsAdmin();
if (!admin.toursOn) return null;
return (
<SettingsCard
icon={Waves}
title={t('admin.paddleRegions.title')}
hint={t('admin.paddleRegions.hint')}
badge={admin.regions.length > 0 ? <StatusPill>{admin.regions.length}</StatusPill> : undefined}
>
{admin.offline && (
<p
role="status"
className="m-0 flex items-center gap-2 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-warning"
style={fs(12.5, 'body')}
>
<WifiOff size={14} className="flex-none" />
{t('admin.paddleRegions.offline')}
</p>
)}
{admin.error && !admin.offline && (
<div
role="alert"
className="flex flex-wrap items-center gap-3 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger"
style={fs(12.5, 'body')}
>
<span className="min-w-0 flex-1 basis-48">{admin.error}</span>
<button type="button" onClick={admin.retry} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
{t('admin.paddleRegions.retry')}
</button>
</div>
)}
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
<CatalogSearch admin={admin} />
<RegionList admin={admin} />
</fieldset>
<SettingsHint>{t('admin.paddleRegions.attribution')}</SettingsHint>
<ConfirmDialog
isOpen={admin.removing !== null}
onClose={admin.cancelRemove}
onConfirm={admin.confirmRemove}
title={t('admin.paddleRegions.remove')}
message={t('admin.paddleRegions.removeMessage', { name: admin.removing?.name ?? '' })}
confirmLabel={t('common.remove')}
/>
</SettingsCard>
);
}
/** A Geofabrik region with the regions it lies in, outermost first, so two of one name tell apart. */
function RegionPath({ hit }: { hit: PaddleRegionCatalogEntry }) {
if (hit.path.length === 0) return null;
return (
<span className="flex min-w-0 flex-wrap items-center gap-0.5 text-content-faint" style={fs(11.5)}>
{hit.path.map((part, index) => (
<span key={`${part}-${index}`} className="inline-flex items-center gap-0.5">
{index > 0 && <ChevronRight size={11} className="flex-none rtl:rotate-180" aria-hidden="true" />}
{part}
</span>
))}
</span>
);
}
function CatalogSearch({ admin }: { admin: Admin }) {
const { t } = useTranslation();
const id = useId();
const { search } = admin;
return (
<div className="flex flex-col gap-2">
<EditorField label={t('admin.paddleRegions.search')} htmlFor={id}>
<div className="relative">
<Search
size={14}
className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint"
aria-hidden="true"
/>
<input
id={id}
type="search"
className={`${INPUT} ps-9`}
value={search.query}
onChange={(event) => search.setQuery(event.target.value)}
placeholder={t('admin.paddleRegions.searchPlaceholder')}
autoComplete="off"
/>
</div>
</EditorField>
{search.searching && (
<p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
<Loader2 size={14} className="flex-none animate-spin" />
{t('common.loading')}
</p>
)}
{search.error && (
<p role="alert" className="m-0 text-danger" style={fs(12.5, 'body')}>
{search.error}
</p>
)}
{search.hits && search.hits.length === 0 && <SettingsHint>{t('admin.paddleRegions.noHits')}</SettingsHint>}
{search.hits && search.hits.length > 0 && (
<SettingRows>
{search.hits.map((hit) => (
<div key={hit.id} className="flex items-center gap-3 py-2 pe-2 ps-3.5" data-testid="paddle-region-hit">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
{hit.name}
</span>
<RegionPath hit={hit} />
</div>
{admin.added.has(hit.id) ? (
<StatusPill icon={<Check size={11} strokeWidth={2.4} />}>{t('admin.paddleRegions.added')}</StatusPill>
) : (
<button
type="button"
disabled={admin.busyId !== null}
onClick={() => void admin.add(hit.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{admin.busyId === hit.id ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Plus size={13} strokeWidth={2.2} />
)}
{t('common.add')}
</button>
)}
</div>
))}
</SettingRows>
)}
</div>
);
}
function RegionList({ admin }: { admin: Admin }) {
const { t } = useTranslation();
if (admin.loading) {
return (
<p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
<Loader2 size={14} className="flex-none animate-spin" />
{t('common.loading')}
</p>
);
}
if (admin.regions.length === 0) {
return (
<div className="rounded-[12px] border border-dashed border-edge px-4 py-5 text-center">
<SettingsHint>{t('admin.paddleRegions.empty')}</SettingsHint>
</div>
);
}
return (
<SettingRows>
{admin.regions.map((region) => (
<RegionRow key={region.id} region={region} admin={admin} />
))}
</SettingRows>
);
}
function RegionStatus({ region }: { region: PaddleRegion }) {
const { t, language } = useTranslation();
const percent = progressPercent(region);
const label =
region.status === 'importing' && region.progress
? t(`admin.paddleRegions.phase.${region.progress.phase}`)
: t(`admin.paddleRegions.status.${region.status}`);
const share =
percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`;
return (
<StatusPill
tone={regionStatusTone(region.status)}
icon={region.status === 'importing' ? <Loader2 size={11} className="animate-spin" /> : undefined}
>
{label}
{share}
</StatusPill>
);
}
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
const { t, language } = useTranslation();
const busy = admin.busyId === region.id;
const date = region.source_timestamp
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
: null;
return (
<div
className={`flex items-center gap-3 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
data-testid="paddle-region-row"
>
<span
aria-hidden
className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary"
>
<Waves size={15} strokeWidth={1.9} />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
{region.name}
</span>
<span className="flex flex-wrap items-center gap-1.5">
<RegionStatus region={region} />
{region.imported_at && (
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
)}
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
</span>
{region.status === 'failed' && region.error && (
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
{region.error}
{region.imported_at ? ` ${t('admin.paddleRegions.keptData')}` : ''}
</p>
)}
</div>
<Tooltip label={t('admin.paddleRegions.refresh')}>
<button
type="button"
aria-label={`${t('admin.paddleRegions.refresh')} ${region.name}`}
disabled={admin.busyId !== null || region.status === 'queued' || region.status === 'importing'}
onClick={() => void admin.refresh(region.id)}
className={SETTINGS_ICON_BUTTON}
>
<RefreshCw size={14} strokeWidth={2} />
</button>
</Tooltip>
<Tooltip label={t('admin.paddleRegions.remove')}>
<button
type="button"
aria-label={`${t('admin.paddleRegions.remove')} ${region.name}`}
disabled={admin.busyId !== null}
onClick={() => admin.askRemove(region)}
className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
>
<Trash2 size={14} strokeWidth={2} />
</button>
</Tooltip>
</div>
);
}
@@ -0,0 +1,20 @@
import type { PaddleRegion } from '@trek/shared';
/** A region waiting for its read or being read: the list asks again until none is. */
export function isReading(region: Pick<PaddleRegion, 'status'>): boolean {
return region.status === 'queued' || region.status === 'importing';
}
/** The share of a read under way as a whole percent, or null while its size is unknown. */
export function progressPercent(region: Pick<PaddleRegion, 'progress'>): number | null {
const fraction = region.progress?.fraction;
return fraction === null || fraction === undefined ? null : Math.round(fraction * 100);
}
/** The status badge's tone: what state the read is in, never whether the water is safe. */
export function regionStatusTone(status: PaddleRegion['status']): 'neutral' | 'accent' | 'success' | 'danger' {
if (status === 'importing') return 'accent';
if (status === 'ready') return 'success';
if (status === 'failed') return 'danger';
return 'neutral';
}
@@ -0,0 +1,379 @@
/**
* FE-HOOK-PADDLEREGIONS-001 to FE-HOOK-PADDLEREGIONS-014: the paddle regions in
* the admin area (PAD-05A), the one hook the desktop and the phone admin render
* through PaddleRegionsPanel. The repo is mocked; its error codes and its
* offline error stay the real ones, and the strings are the English ones.
* FE-PADDLEREGIONSMODEL-001: the model's helpers for the list and its badges.
*/
import { act, renderHook } from '@testing-library/react';
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { createElement, type ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { PaddleRegionsOfflineError } from '../../../repo/paddleRegionRepo';
import { useAddonStore } from '../../../store/addonStore';
import { setForcedOffline } from '../../../sync/networkMode';
import { isReading, progressPercent, regionStatusTone } from './paddleRegionsModel';
import { CATALOG_SEARCH_DEBOUNCE_MS, PADDLE_REGIONS_POLL_MS, usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
const repo = vi.hoisted(() => ({
list: vi.fn<() => Promise<PaddleRegion[]>>(),
catalog: vi.fn<(q: string, signal?: AbortSignal) => Promise<PaddleRegionCatalogEntry[]>>(),
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
paddleRegionRepo: repo,
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const OFFLINE = 'Regions can only be managed with a connection.';
/** A region as the server lists it. */
function region(id: string, status: PaddleRegion['status'], over: Partial<PaddleRegion> = {}): PaddleRegion {
return {
id,
name: id,
status,
source_timestamp: null,
imported_at: null,
feature_count: 0,
error: null,
progress: null,
...over,
};
}
/** A region in Geofabrik's list. */
function hit(id: string, name: string, ...path: string[]): PaddleRegionCatalogEntry {
return { id, name, path };
}
const READY = region('oberbayern', 'ready', { source_timestamp: '2026-10-01T20:21:00Z', feature_count: 412 });
const FAILED = region('niederbayern', 'failed', { error: 'Download failed' });
const QUEUED = region('oberpfalz', 'queued');
const IMPORTING = region('oberpfalz', 'importing', { progress: { phase: 'read', fraction: 0.4 } });
const DONE = region('oberpfalz', 'ready', { source_timestamp: '2026-10-15T20:21:00Z', feature_count: 87 });
const HIT = hit('oberpfalz', 'Oberpfalz', 'Europe', 'Germany', 'Bayern');
const OBERBAYERN_HIT = hit('oberbayern', 'Oberbayern', 'Europe', 'Germany', 'Bayern');
/** An axios error as the server answers it: its message and, for some, a code. */
function apiError(error: string, code?: string) {
return { response: { data: { error, code } } };
}
/** A promise the test settles itself, to look at the hook while a call is out. */
function deferred<T>() {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((r) => (resolve = r));
return { promise, resolve };
}
/** Lets the clock run on and every answer due by then arrive. */
async function settle(ms = 0) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
async function mount() {
const hook = renderHook(() => usePaddleRegionsAdmin(), { wrapper });
await settle();
return hook;
}
function seedTours() {
seedStore(useAddonStore, { addons: [{ id: 'tours', enabled: true }] });
}
beforeEach(() => {
vi.useFakeTimers();
resetAllStores();
seedTours();
toast.success.mockReset();
toast.error.mockReset();
repo.list.mockReset().mockResolvedValue([READY]);
repo.catalog.mockReset().mockResolvedValue([HIT]);
repo.add.mockReset().mockResolvedValue(QUEUED);
repo.refresh.mockReset().mockResolvedValue(READY);
repo.remove.mockReset().mockResolvedValue({ success: true });
});
afterEach(() => {
act(() => setForcedOffline(false));
vi.useRealTimers();
});
describe('usePaddleRegionsAdmin', () => {
it('FE-HOOK-PADDLEREGIONS-001: asks nothing while the Tours addon is off, and loads once it is switched on', async () => {
seedStore(useAddonStore, { addons: [] });
const { result } = await mount();
await settle(PADDLE_REGIONS_POLL_MS * 2);
expect(result.current.toursOn).toBe(false);
expect(repo.list).not.toHaveBeenCalled();
act(() => seedTours());
await settle();
expect(result.current.toursOn).toBe(true);
expect(repo.list).toHaveBeenCalledTimes(1);
expect(result.current.regions).toEqual([READY]);
});
it('FE-HOOK-PADDLEREGIONS-002: loads the list, knows which regions are added and asks no more while none is read', async () => {
repo.list.mockResolvedValue([READY, FAILED]);
const { result } = renderHook(() => usePaddleRegionsAdmin(), { wrapper });
expect(result.current).toMatchObject({ toursOn: true, offline: false, loading: true, regions: [], error: null });
await settle();
expect(result.current).toMatchObject({ loading: false, regions: [READY, FAILED], error: null, busyId: null });
expect([...result.current.added]).toEqual(['oberbayern', 'niederbayern']);
expect(result.current.removing).toBeNull();
await settle(PADDLE_REGIONS_POLL_MS * 3);
expect(repo.list).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-PADDLEREGIONS-003: asks again on every poll while a region waits or is read, and stops once all are ready', async () => {
repo.list
.mockResolvedValueOnce([READY, QUEUED])
.mockResolvedValueOnce([READY, IMPORTING])
.mockResolvedValue([READY, DONE]);
const { result } = await mount();
expect(repo.list).toHaveBeenCalledTimes(1);
await settle(PADDLE_REGIONS_POLL_MS - 1);
expect(repo.list).toHaveBeenCalledTimes(1);
await settle(1);
expect(repo.list).toHaveBeenCalledTimes(2);
expect(result.current.regions).toEqual([READY, IMPORTING]);
await settle(PADDLE_REGIONS_POLL_MS);
expect(repo.list).toHaveBeenCalledTimes(3);
expect(result.current.regions).toEqual([READY, DONE]);
await settle(PADDLE_REGIONS_POLL_MS * 5);
expect(repo.list).toHaveBeenCalledTimes(3);
});
it('FE-HOOK-PADDLEREGIONS-004: a failed poll shows the error and keeps the regions; the next answer clears it', async () => {
repo.list
.mockResolvedValueOnce([QUEUED])
.mockRejectedValueOnce(apiError('Database is locked'))
.mockResolvedValue([DONE]);
const { result } = await mount();
await settle(PADDLE_REGIONS_POLL_MS);
expect(result.current).toMatchObject({ error: 'Database is locked', regions: [QUEUED], loading: false });
await settle(PADDLE_REGIONS_POLL_MS);
expect(result.current).toMatchObject({ error: null, regions: [DONE] });
await settle(PADDLE_REGIONS_POLL_MS * 3);
expect(repo.list).toHaveBeenCalledTimes(3);
});
it('FE-HOOK-PADDLEREGIONS-005: names why the list failed, offline or unknown, and a retry clears it', async () => {
repo.list
.mockRejectedValueOnce(new PaddleRegionsOfflineError())
.mockRejectedValueOnce('socket hang up')
.mockResolvedValue([READY]);
const { result } = await mount();
expect(result.current).toMatchObject({ error: OFFLINE, loading: false, regions: [] });
await act(() => result.current.retry());
expect(result.current.error).toBe('Error');
await act(() => result.current.retry());
expect(result.current).toMatchObject({ error: null, regions: [READY] });
});
it('FE-HOOK-PADDLEREGIONS-006: searches Geofabrik only from two characters and once the typing rests', async () => {
const { result } = await mount();
act(() => result.current.search.setQuery(' o '));
await settle(CATALOG_SEARCH_DEBOUNCE_MS * 3);
expect(repo.catalog).not.toHaveBeenCalled();
expect(result.current.search).toMatchObject({ query: ' o ', hits: null, searching: false, error: null });
act(() => result.current.search.setQuery(' ob'));
expect(result.current.search.searching).toBe(true);
await settle(CATALOG_SEARCH_DEBOUNCE_MS - 1);
act(() => result.current.search.setQuery(' obe'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS - 1);
expect(repo.catalog).not.toHaveBeenCalled();
await settle(1);
expect(repo.catalog).toHaveBeenCalledTimes(1);
expect(repo.catalog).toHaveBeenCalledWith('obe', expect.any(AbortSignal));
expect(result.current.search).toMatchObject({ query: ' obe', hits: [HIT], searching: false, error: null });
});
it('FE-HOOK-PADDLEREGIONS-007: the answer for the words typed last wins, an older one arriving late is dropped', async () => {
const first = deferred<PaddleRegionCatalogEntry[]>();
const second = deferred<PaddleRegionCatalogEntry[]>();
repo.catalog.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const { result } = await mount();
act(() => result.current.search.setQuery('ober'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
act(() => result.current.search.setQuery('oberpf'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
expect(repo.catalog.mock.calls.map(([q]) => q)).toEqual(['ober', 'oberpf']);
expect(repo.catalog.mock.calls[0][1]?.aborted).toBe(true);
first.resolve([OBERBAYERN_HIT, HIT]);
await settle();
expect(result.current.search).toMatchObject({ hits: null, searching: true });
second.resolve([HIT]);
await settle();
expect(result.current.search).toMatchObject({ query: 'oberpf', hits: [HIT], searching: false });
});
it('FE-HOOK-PADDLEREGIONS-008: says when Geofabrik cannot be reached, until the words are too short to search', async () => {
repo.catalog.mockRejectedValueOnce(apiError('Geofabrik did not answer', 'catalog_unavailable'));
const { result } = await mount();
act(() => result.current.search.setQuery('oberpfalz'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
expect(result.current.search).toMatchObject({
hits: null,
searching: false,
error: 'Geofabrik cannot be reached right now. Try again later.',
});
act(() => result.current.search.setQuery('o'));
expect(result.current.search.error).toBeNull();
});
it('FE-HOOK-PADDLEREGIONS-009: adding a region clears the search, says so and lists it', async () => {
repo.list.mockResolvedValueOnce([READY]).mockResolvedValue([READY, QUEUED]);
const { result } = await mount();
act(() => result.current.search.setQuery('oberpfalz'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
expect(result.current.added.has('oberpfalz')).toBe(false);
await act(() => result.current.add('oberpfalz'));
expect(repo.add).toHaveBeenCalledWith('oberpfalz');
expect(toast.success).toHaveBeenCalledWith('Region added. TREK reads it in the background.');
expect(toast.error).not.toHaveBeenCalled();
expect(result.current.search).toMatchObject({ query: '', hits: null, searching: false });
expect(repo.list).toHaveBeenCalledTimes(2);
expect(result.current.regions).toEqual([READY, QUEUED]);
expect(result.current.added.has('oberpfalz')).toBe(true);
expect(result.current.busyId).toBeNull();
});
it('FE-HOOK-PADDLEREGIONS-010: a refused add shows the server message and keeps the words typed', async () => {
repo.add.mockRejectedValueOnce(apiError('Geofabrik has no region oberpfalz-nord', 'unknown_region'));
const { result } = await mount();
act(() => result.current.search.setQuery('oberpfalz-nord'));
await act(() => result.current.add('oberpfalz-nord'));
expect(toast.error).toHaveBeenCalledWith('Geofabrik has no region oberpfalz-nord');
expect(toast.success).not.toHaveBeenCalled();
expect(result.current.search.query).toBe('oberpfalz-nord');
expect(repo.list).toHaveBeenCalledTimes(2);
expect(result.current.busyId).toBeNull();
});
it('FE-HOOK-PADDLEREGIONS-011: reading a region again asks quietly and reloads the list; a failure says why', async () => {
const { result } = await mount();
await act(() => result.current.refresh('oberbayern'));
expect(repo.refresh).toHaveBeenCalledWith('oberbayern');
expect(toast.success).not.toHaveBeenCalled();
expect(repo.list).toHaveBeenCalledTimes(2);
repo.refresh.mockRejectedValueOnce(new PaddleRegionsOfflineError());
await act(() => result.current.refresh('oberbayern'));
expect(toast.error).toHaveBeenCalledWith(OFFLINE);
expect(repo.list).toHaveBeenCalledTimes(3);
});
it('FE-HOOK-PADDLEREGIONS-012: removes a region only once the removal is confirmed', async () => {
const { result } = await mount();
act(() => result.current.askRemove(READY));
expect(result.current.removing).toEqual(READY);
act(() => result.current.cancelRemove());
expect(result.current.removing).toBeNull();
act(() => result.current.confirmRemove());
expect(repo.remove).not.toHaveBeenCalled();
expect(repo.list).toHaveBeenCalledTimes(1);
repo.list.mockResolvedValue([]);
act(() => result.current.askRemove(READY));
act(() => result.current.confirmRemove());
expect(repo.remove).toHaveBeenCalledWith('oberbayern');
expect(result.current).toMatchObject({ removing: null, busyId: 'oberbayern' });
await settle();
expect(toast.success).toHaveBeenCalledWith('Region removed');
expect(result.current).toMatchObject({ busyId: null, regions: [] });
expect(result.current.added.size).toBe(0);
});
it('FE-HOOK-PADDLEREGIONS-013: while one action runs, a second one is ignored', async () => {
const reading = deferred<PaddleRegion>();
repo.refresh.mockReturnValueOnce(reading.promise);
const { result } = await mount();
act(() => {
void result.current.refresh('oberbayern');
});
expect(result.current.busyId).toBe('oberbayern');
await act(() => result.current.add('oberpfalz'));
act(() => result.current.askRemove(READY));
act(() => result.current.confirmRemove());
expect(repo.add).not.toHaveBeenCalled();
expect(repo.remove).not.toHaveBeenCalled();
expect(repo.refresh).toHaveBeenCalledTimes(1);
expect(result.current.busyId).toBe('oberbayern');
reading.resolve(READY);
await settle();
expect(result.current.busyId).toBeNull();
expect(repo.list).toHaveBeenCalledTimes(2);
await act(() => result.current.add('oberpfalz'));
expect(repo.add).toHaveBeenCalledWith('oberpfalz');
});
it('FE-HOOK-PADDLEREGIONS-014: offline it asks nothing and shows no spinner, and picks up again with the connection', async () => {
repo.list.mockResolvedValue([QUEUED]);
setForcedOffline(true);
const { result } = await mount();
expect(result.current).toMatchObject({ offline: true, loading: false, regions: [], error: null });
act(() => result.current.search.setQuery('oberpfalz'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS * 3);
expect(repo.list).not.toHaveBeenCalled();
expect(repo.catalog).not.toHaveBeenCalled();
expect(result.current.search).toMatchObject({ hits: null, searching: false, error: null });
act(() => setForcedOffline(false));
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
expect(result.current.offline).toBe(false);
expect(repo.list).toHaveBeenCalledTimes(1);
expect(repo.catalog).toHaveBeenCalledWith('oberpfalz', expect.any(AbortSignal));
expect(result.current.search.hits).toEqual([HIT]);
act(() => setForcedOffline(true));
await settle(PADDLE_REGIONS_POLL_MS * 4);
expect(repo.list).toHaveBeenCalledTimes(1);
expect(result.current.loading).toBe(false);
expect(result.current.search.hits).toBeNull();
act(() => setForcedOffline(false));
await settle();
expect(repo.list).toHaveBeenCalledTimes(2);
});
});
describe('paddleRegionsModel', () => {
it('FE-PADDLEREGIONSMODEL-001: waits on queued and importing reads, tones the badge by status and rounds the progress', () => {
const statuses: PaddleRegion['status'][] = ['queued', 'importing', 'ready', 'failed'];
expect(statuses.map((status) => isReading({ status }))).toEqual([true, true, false, false]);
expect(statuses.map((status) => regionStatusTone(status))).toEqual(['neutral', 'accent', 'success', 'danger']);
expect(progressPercent({ progress: null })).toBeNull();
expect(progressPercent({ progress: { phase: 'download', fraction: null } })).toBeNull();
expect(progressPercent({ progress: { phase: 'read', fraction: 0.456 } })).toBe(46);
expect(progressPercent({ progress: { phase: 'read', fraction: 1 } })).toBe(100);
});
});
@@ -0,0 +1,173 @@
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { useTranslation } from '../../../i18n';
import { paddleRegionErrorCode, paddleRegionRepo } from '../../../repo/paddleRegionRepo';
import { useAddonStore } from '../../../store/addonStore';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import { isReading } from './paddleRegionsModel';
/** How often the list is asked again while a region waits or is read. */
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
/** How long typing rests before Geofabrik's list is searched. */
export const CATALOG_SEARCH_DEBOUNCE_MS = import.meta.env.MODE === 'test' ? 10 : 350;
type Translate = ReturnType<typeof useTranslation>['t'];
function errorText(t: Translate, err: unknown): string {
const code = paddleRegionErrorCode(err);
if (code === 'offline') return t('admin.paddleRegions.offline');
if (code === 'catalog_unavailable') return t('admin.paddleRegions.catalogUnavailable');
return getApiErrorMessage(err, t('common.error'));
}
/**
* The regions as the server holds them, asked again every few seconds while
* any of them is read, and on `reload` after a change. Every answer is set
* from the request's own callback, so a slow one never overwrites a newer.
*/
function useRegionList(active: boolean, offline: boolean, t: Translate) {
const [regions, setRegions] = useState<PaddleRegion[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [asked, setAsked] = useState(0);
useEffect(() => {
if (!active || offline) return;
let cancelled = false;
paddleRegionRepo.list().then(
(list) => {
if (cancelled) return;
setRegions(list);
setError(null);
setLoaded(true);
},
(err: unknown) => {
if (cancelled) return;
setError(errorText(t, err));
setLoaded(true);
}
);
return () => {
cancelled = true;
};
}, [active, offline, t, asked]);
const reading = regions.some(isReading);
useEffect(() => {
if (!active || offline || !reading) return;
const timer = window.setInterval(() => setAsked((n) => n + 1), PADDLE_REGIONS_POLL_MS);
return () => window.clearInterval(timer);
}, [active, offline, reading]);
const reload = useCallback(() => setAsked((n) => n + 1), []);
return { regions, loading: !loaded && !offline, error, reload };
}
/** Geofabrik's list searched as the admin types, the last search winning. */
function useCatalogSearch(offline: boolean, t: Translate) {
const [query, setQuery] = useState('');
const [result, setResult] = useState<{
needle: string;
hits: PaddleRegionCatalogEntry[] | null;
error: string | null;
} | null>(null);
const needle = query.trim();
const wanted = !offline && needle.length >= 2;
useEffect(() => {
if (!wanted) return;
const controller = new AbortController();
const timer = window.setTimeout(() => {
paddleRegionRepo
.catalog(needle, controller.signal)
.then((hits) => {
if (!controller.signal.aborted) setResult({ needle, hits, error: null });
})
.catch((err: unknown) => {
if (!controller.signal.aborted) setResult({ needle, hits: null, error: errorText(t, err) });
});
}, CATALOG_SEARCH_DEBOUNCE_MS);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [needle, wanted, t]);
// An answer counts only for the words it was asked for.
const current = wanted && result?.needle === needle ? result : null;
return { query, setQuery, hits: current?.hits ?? null, searching: wanted && !current, error: current?.error ?? null };
}
/**
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
* in Geofabrik's list to add one, reading a region again and removing it
* after a confirmation. Shown only while the Tours addon is on; the status of
* a read follows without reloading the page.
*/
export function usePaddleRegionsAdmin() {
const { t } = useTranslation();
const toast = useToast();
const { offline } = useNetworkMode();
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
const list = useRegionList(toursOn, offline, t);
const search = useCatalogSearch(offline, t);
const [busyId, setBusyId] = useState<string | null>(null);
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
const pending = useRef(false);
const { reload } = list;
const run = useCallback(
async (id: string, action: () => Promise<unknown>, done?: string) => {
if (pending.current) return;
pending.current = true;
setBusyId(id);
try {
await action();
if (done) toast.success(done);
} catch (err) {
toast.error(errorText(t, err));
} finally {
pending.current = false;
setBusyId(null);
reload();
}
},
[reload, t, toast]
);
const { setQuery } = search;
const add = useCallback(
(id: string) =>
run(
id,
async () => {
await paddleRegionRepo.add(id);
setQuery('');
},
t('admin.paddleRegions.toast.added')
),
[run, setQuery, t]
);
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
const confirmRemove = useCallback(() => {
const region = removing;
setRemoving(null);
if (region) void run(region.id, () => paddleRegionRepo.remove(region.id), t('admin.paddleRegions.toast.removed'));
}, [removing, run, t]);
const added = useMemo(() => new Set(list.regions.map((region) => region.id)), [list.regions]);
return {
toursOn,
offline,
regions: list.regions,
loading: list.loading,
error: list.error,
retry: reload,
search,
added,
busyId,
add,
refresh,
removing,
askRemove: setRemoving,
cancelRemove: () => setRemoving(null),
confirmRemove,
};
}
@@ -5,20 +5,12 @@ import type { Ferrata } from '@trek/shared';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance } from '../../utils/units';
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
import { MAP_CARD_GLASS } from '../Map/mapControlShadow';
import { SoftPill } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { ferrataOsmUrl } from './ferrataModel';
/** The map controls' frosted glass, so the card reads as one of them. */
const MAP_GLASS = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
};
/**
* One via ferrata, picked on the map: its name, the route it belongs to, its
* grade range and total length as badges, and the way back to the source.
@@ -57,7 +49,7 @@ export default function FerrataDetailCard({
return (
<article
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
style={{ ...MAP_GLASS, width }}
style={{ ...MAP_CARD_GLASS, width }}
data-testid="ferrata-detail-card"
>
<div className="flex items-start gap-2">
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useRef } from 'react';
import { useMapCardDismiss } from '../Map/useMapCardDismiss';
import FerrataDetailCard from './FerrataDetailCard';
import type { FerrataGroup } from './ferrataGroups';
import type { FerrataSelection } from './useFerrataMapLayer';
@@ -39,26 +40,7 @@ export default function FerrataDetailOverlay({
onTourIds?: ReadonlySet<string>;
}) {
const ref = useRef<HTMLDivElement>(null);
const open = !!selection;
useEffect(() => {
if (!open) return;
const onDown = (event: PointerEvent) => {
const card = ref.current;
if (card && event.target instanceof Node && card.contains(event.target)) return;
onOutsidePress?.();
onClose();
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !event.defaultPrevented) onClose();
};
document.addEventListener('pointerdown', onDown, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown, true);
document.removeEventListener('keydown', onKey);
};
}, [open, onClose, onOutsidePress]);
useMapCardDismiss(ref, !!selection, onClose, onOutsidePress);
if (!selection) return null;
return (
+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);
}
@@ -112,7 +112,13 @@ export function useTripFerratas(
);
// The tiles a tour's line runs through load with the layer off too, for the warning
// that a route climbs a ferrata; only the layer's own state decides what it shows.
const routeKey = routeTiles && routeTiles.length > 0 ? routeTiles.map(ferrataTileKey).sort().join('|') : null;
const routeKey =
routeTiles && routeTiles.length > 0
? routeTiles
.map(ferrataTileKey)
.sort((a, b) => a.localeCompare(b))
.join('|')
: null;
const layerOn = enabled && !!store;
const active = layerOn || (!!routeKey && !!store);
const fits = !!view && ferrataBboxFits(view);
@@ -1,12 +1,13 @@
import { useId, useState } from 'react'
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import { Tooltip } from '../../shared/Tooltip'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useConnectForm } from './useConnectForm'
import { Badge } from './DocSyncBits'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import type { DocSyncProvider, useDocSync } from './useDocSync'
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
/**
* Connecting a store, as a dialog rather than a panel that unfolds in place.
@@ -32,7 +33,7 @@ export default function DocSyncConnectModal({
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const test = async () => setVerdict(await form.probe())
const labelId = useId()
@@ -134,7 +135,7 @@ function Verdict({
busy,
failure,
}: {
verdict: { connected: boolean; account?: string; error?: string } | null
verdict: DocSyncVerdict | null
busy: boolean
/** A call that failed outside the connection test (saving, most of all). */
failure: string | null
@@ -172,10 +173,16 @@ function Verdict({
)
}
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
// The footer shares its row with the buttons, and a refusal cut to "The
// credentials were re..." with no way to read the rest left nothing to go on
// (#2666). Two lines, and the whole of it with the store's reason on hover.
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
</span>
<Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top">
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
{blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />}
<span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span>
</span>
</Tooltip>
)
}
@@ -1,5 +1,5 @@
import { useState } from 'react'
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
import type { DocSyncConnection, DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
/**
* The credential form's rules, for both shells.
@@ -39,7 +39,7 @@ export interface ConnectForm {
setInsecureTls: (on: boolean) => void
/** Every required field is either filled in or already stored. */
complete: boolean
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
probe: () => Promise<DocSyncVerdict>
save: () => Promise<boolean>
}
@@ -12,6 +12,14 @@ import { useServerPing } from '../../../sync/useServerPing'
* reason for the same problem.
*/
/** What a connection test answers; `detail` is the store's own reason, for the person fixing it. */
export interface DocSyncVerdict {
connected: boolean
account?: string
error?: string
detail?: string
}
export interface DocSyncField {
field_key: string
label: string
@@ -264,11 +272,9 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('test')
try {
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
connected: boolean; account?: string; error?: string; detail?: string
}
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as DocSyncVerdict
} catch (e: unknown) {
return { connected: false, error: readError(e) }
return { connected: false, error: readError(e) } satisfies DocSyncVerdict
} finally {
setBusy(null)
}
+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"
+8 -50
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'
@@ -61,12 +60,14 @@ import type { DayBoundaryControls } from './dayBoundaryDrag'
import type { Poi } from './poiCategories'
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { SelectedTrackLine, TrackLines } from './TrackLines'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
@@ -830,7 +831,7 @@ export const MapView = memo(function MapView({
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
}: MapViewProps) {
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
@@ -1034,55 +1035,11 @@ export const MapView = memo(function MapView({
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
// Keeps the click handler out of the polyline memo below: `handleMarkerClick`
// Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
// changes on every selection, and depending on it would redraw all tracks.
const markerClickRef = useRef(handleMarkerClick)
markerClickRef.current = handleMarkerClick
const gpxPolylines = useMemo(() => (
<>
{/* Casings live in their own pane below the lines. Leaflet stacks paths by
insertion order, so drawing them inline would put the casing of a track
added later — or of one just given a colour — on top of an earlier
track's line. Always rendered, hidden via opacity when there is no
colour, so toggling one never remounts the path. */}
{gpxTracks.map(({ place, coords, cased }) => (
<Polyline
key={`gpx-${place.id}-casing`}
positions={coords}
pane={hasCasingPane ? TRACK_CASING_PANE : undefined}
pathOptions={{ color: '#ffffff', weight: 6.5, opacity: cased ? 0.7 : 0, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
))}
{/* pathOptions, not bare color/weight props: react-leaflet only calls
setStyle when the pathOptions reference changes, so bare props would
stick at their mount-time colour and a repaint would never arrive. */}
{gpxTracks.map(({ place, coords, cased, color }) => (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
interactive={false}
/>
))}
{/* Invisible fat line on top so the track can actually be hit — 3.5px is
not a target, least of all on touch, and the start markers cluster below
zoom 11. bubblingMouseEvents is essential: paths bubble to the map by
default (markers do not), and the map's own click handler clears the
selection this one just made. */}
{gpxTracks.map(({ place, coords }) => (
<Polyline
key={`gpx-${place.id}-hit`}
positions={coords}
pathOptions={{ color: '#000', weight: 14, opacity: 0, lineCap: 'round', lineJoin: 'round' }}
bubblingMouseEvents={false}
eventHandlers={{ click: () => markerClickRef.current(place.id) }}
/>
))}
</>
), [gpxTracks, hasCasingPane])
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
@@ -1312,7 +1269,8 @@ export const MapView = memo(function MapView({
{/* GPX imported route geometries */}
<TrackCasingPane onReady={setHasCasingPane} />
{gpxPolylines}
<TrackLines tracks={gpxTracks} casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined} selectRef={markerClickRef} />
<SelectedTrackLine tracks={gpxTracks} selectedPlaceId={selectedPlaceId} />
{routeProfileFocus && (
<CircleMarker
@@ -1333,7 +1291,7 @@ export const MapView = memo(function MapView({
/>
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
<FerrataMapLayerLeaflet layer={ferrataLayer} />
<TourMapLayersLeaflet ferrataLayer={ferrataLayer} overlays={tourLayers?.overlays} paddle={tourLayers?.paddle} />
{/* The route as it was actually recorded (#2279). Drawn in the casing
pane's sibling order so it sits under the planned route rather than
@@ -1381,7 +1339,7 @@ export const MapView = memo(function MapView({
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} />
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
</div>
</div>
+18 -132
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'
@@ -41,11 +42,10 @@ import PlaceHoverCard from './PlaceHoverCard'
import { ratingBadgeHtml } from './ratingBadge'
import type { Poi } from './poiCategories'
import { poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
@@ -66,26 +66,6 @@ const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
* layer of its own, drawn over the style and under everything TREK adds — the route,
* the pins and the tracks stay on top and stay legible, which is the same order the
* Leaflet map ends up with.
*
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
*/
const SATELLITE_SOURCE_ID = 'trip-satellite'
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
// route source so they sit under the current route, which also puts them before the first
// `trip-` layer, and anchoring the imagery there painted right over them.
const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-']
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -203,59 +183,6 @@ function addPlaceClusterLayers(map: any): void {
})
}
/**
* Puts the imagery on the map, or takes it off again.
*
* Built on demand rather than once at load: a style change drops every source the map
* had, and a hot reload does the same. Visibility comes first: a layer that is already
* there only needs flipping, so the usual pass is one call and a tap answers at once.
*
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
* the usual state, and the only retry here is `styledata`, which fires on style edits
* and not when those finish. Behind that gate a stored choice met a busy map on load,
* and a tap on the road trip stage met the sources the same render had just set again,
* so the imagery was never drawn. The gate was also the wrong question: this only runs
* after `load`, and addSource and addLayer refuse only a style document that is not in
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applySatellite(map: any, on: boolean, topo = false): void {
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID
try {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none')
return
}
if (!on) return // Nothing to build while it is switched off.
// The layer is what is missing, not necessarily the source: a pass that got the
// source in and then failed on the layer would otherwise leave a source that stops
// every later pass from ever building the layer.
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'raster',
tiles: topo
? ['a', 'b', 'c'].map(subdomain => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
: [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
})
}
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? []
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: layerId,
type: 'raster',
source: sourceId,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
} catch { /* a style that refuses the layer keeps the plain basemap */ }
}
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
@@ -626,7 +553,7 @@ export function MapViewGL({
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
roadtripVias,
onMoveVia,
onRemoveVia,
@@ -685,7 +612,7 @@ export function MapViewGL({
const mapRef = useRef<any | null>(null)
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
useFerrataMapLayerGL(mapReady ? readyMap : null, ferrataLayer)
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays, tourLayers?.paddle)
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1345,40 +1272,8 @@ export function MapViewGL({
})
}
// gpx geometries source (place.route_geometry)
if (!map.getSource('trip-gpx')) {
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
// Casing under the tracks that carry a picked colour (#776) — keeps them
// legible on satellite and dark styles. Untouched tracks are filtered
// out, so they look exactly as they did before.
map.addLayer({
id: 'trip-gpx-casing',
type: 'line',
source: 'trip-gpx',
filter: ['==', ['get', 'cased'], true],
paint: { 'line-color': '#ffffff', 'line-width': 6.5, 'line-opacity': 0.7 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'trip-gpx-line',
type: 'line',
source: 'trip-gpx',
paint: {
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
'line-width': 3.5,
'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// Invisible fat line that catches the click — 3.5px is not a target,
// and the start markers cluster below zoom 11, so without this a track
// is unreachable at the very zoom where you compare walks side by side.
map.addLayer({
id: GPX_HIT_LAYER_ID,
type: 'line',
source: 'trip-gpx',
paint: { 'line-color': '#000', 'line-width': 14, 'line-opacity': 0 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
if (!hasGpxTrackLayers(map)) {
addGpxTrackLayers(map)
const selectTrack = (e: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
// A click on a cluster bubble sitting over a track belongs to the
// cluster (zoom-to-expand), not to the line underneath it.
@@ -1534,7 +1429,7 @@ export function MapViewGL({
&& typeof map.queryRenderedFeatures === 'function'
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
if (isFerrataHit(map, e.point)) return // a picked via ferrata opens its card instead
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
})
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
@@ -2075,24 +1970,15 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map) return
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
if (!src) return
const features = places.flatMap(place => {
const coords = parseRenderableRouteGeometry(place.route_geometry)
if (!coords) return []
return [{
type: 'Feature' as const,
properties: {
color: resolveTrackColor(place),
cased: hasManualTrackColor(place),
place_id: place.id,
},
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
}]
})
src.setData({ type: 'FeatureCollection', features })
setGpxTracks(map, places)
}, [places, mapReady])
// The picked tour's track stands out (PR1-08).
useEffect(() => {
const map = mapRef.current
if (map && mapReady) selectGpxTrack(map, selectedPlaceId)
}, [selectedPlaceId, mapReady])
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
// circle arcs for flights/cruises, straight lines for trains/cars,
// clickable endpoint badges, rotating mid-arc stats label for flights.
@@ -2354,7 +2240,7 @@ export function MapViewGL({
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange}
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} />
: <MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />}
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
@@ -0,0 +1,50 @@
import { TileLayer } from 'react-leaflet';
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet';
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
import { PaddleLayerLeaflet } from '../Tours/paddle/PaddleLayerLeaflet';
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
import type { MapRasterOverlay } from './mapRasterOverlay';
/** Over the basemap's tiles (z-index 1) and every basemap raster, under the route's own panes. */
const OVERLAY_Z_INDEX = 300;
/** The overlays on the Leaflet map: a tile layer each while it is on, nothing while it is off. */
function RasterOverlaysLeaflet({ overlays }: { overlays?: readonly MapRasterOverlay[] }) {
return (
<>
{overlays
?.filter((overlay) => overlay.active)
.map((overlay) => (
<TileLayer
key={overlay.id}
url={overlay.url}
attribution={overlay.attribution}
minZoom={overlay.minZoom}
maxNativeZoom={overlay.maxZoom}
maxZoom={22}
zIndex={OVERLAY_Z_INDEX}
eventHandlers={{ tileerror: overlay.onTileError, tileload: overlay.onTileLoad }}
/>
))}
</>
);
}
/** The tour planner's layers on the Leaflet map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */
export function TourMapLayersLeaflet({
ferrataLayer,
overlays,
paddle,
}: {
ferrataLayer?: FerrataMapLayer;
overlays?: readonly MapRasterOverlay[];
paddle?: PaddleMapLayer;
}) {
return (
<>
<FerrataMapLayerLeaflet layer={ferrataLayer} />
<RasterOverlaysLeaflet overlays={overlays} />
<PaddleLayerLeaflet layer={paddle} />
</>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { memo, type RefObject } from 'react';
import { Polyline } from 'react-leaflet';
import type { Place } from '../../types';
import { TRACK_CASING_COLOR } from './trackColors';
/** A place's track as the Leaflet map draws it: its points, its colour, and whether it carries a rim. */
export interface TrackLine {
place: Place;
coords: [number, number][];
cased: boolean;
color: string;
}
interface TrackLinesProps {
tracks: readonly TrackLine[];
/** The pane the rims go into, below the lines; none while the map has not made it yet. */
casingPane?: string;
/** Read on click, so a new selection handler never redraws every track. */
selectRef: RefObject<(placeId: number) => void>;
}
/**
* Every track of the map: the rims, the lines and an invisible fat line on top
* that takes the click. Memoised on the tracks alone; a selection changes
* nothing here, the picked track is drawn by {@link SelectedTrackLine}.
*/
export const TrackLines = memo(function TrackLines({ tracks, casingPane, selectRef }: TrackLinesProps) {
return (
<>
{/* Casings live in their own pane below the lines. Leaflet stacks paths by
insertion order, so drawing them inline would put the casing of a track
added later — or of one just given a colour — on top of an earlier
track's line. Always rendered, hidden via opacity when there is no
colour, so toggling one never remounts the path. */}
{tracks.map(({ place, coords, cased }) => (
<Polyline
key={`gpx-${place.id}-casing`}
positions={coords}
pane={casingPane}
pathOptions={{
color: TRACK_CASING_COLOR,
weight: 6.5,
opacity: cased ? 0.7 : 0,
lineCap: 'round',
lineJoin: 'round',
}}
interactive={false}
/>
))}
{/* pathOptions, not bare color/weight props: react-leaflet only calls
setStyle when the pathOptions reference changes, so bare props would
stick at their mount-time colour and a repaint would never arrive. */}
{tracks.map(({ place, coords, cased, color }) => (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
interactive={false}
/>
))}
{/* Invisible fat line on top so the track can actually be hit — 3.5px is
not a target, least of all on touch, and the start markers cluster below
zoom 11. bubblingMouseEvents is essential: paths bubble to the map by
default (markers do not), and the map's own click handler clears the
selection this one just made. */}
{tracks.map(({ place, coords }) => (
<Polyline
key={`gpx-${place.id}-hit`}
positions={coords}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 14, opacity: 0, lineCap: 'round', lineJoin: 'round' }}
bubblingMouseEvents={false}
eventHandlers={{ click: () => selectRef.current?.(place.id) }}
/>
))}
</>
);
});
/**
* PR1-08: the track of the picked tour, wider and with a light rim on top of
* the others, so the line and the list's row read as one. It takes no click:
* the hit line below still does.
*/
export function SelectedTrackLine({
tracks,
selectedPlaceId,
}: {
tracks: readonly TrackLine[];
selectedPlaceId: number | null;
}) {
const track = selectedPlaceId == null ? null : tracks.find((item) => item.place.id === selectedPlaceId);
if (!track) return null;
return (
<>
<Polyline
key={`gpx-selected-${track.place.id}-casing`}
positions={track.coords}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 9, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
<Polyline
key={`gpx-selected-${track.place.id}`}
positions={track.coords}
pathOptions={{ color: track.color, weight: 5.5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
</>
);
}
@@ -0,0 +1,92 @@
import {
OPENTOPOMAP_TILE_ATTRIBUTION,
OPENTOPOMAP_TILE_MAXZOOM,
OPENTOPOMAP_TILE_URL,
SATELLITE_TILE_ATTRIBUTION,
SATELLITE_TILE_MAXZOOM,
SATELLITE_TILE_URL,
} from '../../constants/mapDefaults';
/*
* The raster layers a GL map draws between its basemap style and what TREK
* adds on top: the satellite and topo base layers, and (in mapRasterOverlay.ts)
* the transparent overlays such as the seamarks.
*/
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
* layer of its own, drawn over the style and under everything TREK adds — the route,
* the pins and the tracks stay on top and stay legible, which is the same order the
* Leaflet map ends up with.
*
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
*/
const SATELLITE_SOURCE_ID = 'trip-satellite';
const SATELLITE_LAYER_ID = 'trip-satellite-raster';
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
// route source so they sit under the current route, which also puts them before the first
// `trip-` layer, and anchoring the imagery there painted right over them.
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
/**
* Puts the imagery on the map, or takes it off again.
*
* Built on demand rather than once at load: a style change drops every source the map
* had, and a hot reload does the same. Visibility comes first: a layer that is already
* there only needs flipping, so the usual pass is one call and a tap answers at once.
*
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
* the usual state, and the only retry here is `styledata`, which fires on style edits
* and not when those finish. Behind that gate a stored choice met a busy map on load,
* and a tap on the road trip stage met the sources the same render had just set again,
* so the imagery was never drawn. The gate was also the wrong question: this only runs
* after `load`, and addSource and addLayer refuse only a style document that is not in
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function applySatellite(map: any, on: boolean, topo = false): void {
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID;
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID;
try {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
return;
}
if (!on) return; // Nothing to build while it is switched off.
// The layer is what is missing, not necessarily the source: a pass that got the
// source in and then failed on the layer would otherwise leave a source that stops
// every later pass from ever building the layer.
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'raster',
tiles: topo
? ['a', 'b', 'c'].map((subdomain) => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
: [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
});
}
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? [];
const firstOwn = layers.find((l: { id: string }) => OWN_LAYER_PREFIXES.some((prefix) => l.id.startsWith(prefix)));
map.addLayer(
{
id: layerId,
type: 'raster',
source: sourceId,
paint: { 'raster-opacity': 1 },
},
firstOwn?.id
);
} catch {
/* a style that refuses the layer keeps the plain basemap */
}
}
@@ -0,0 +1,66 @@
import type mapboxgl from 'mapbox-gl';
import { describe, expect, it, vi } from 'vitest';
import type { Place } from '../../types';
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers';
function fakeMap() {
const layers = new Map<string, { filter?: unknown }>();
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
const map = {
addSource: vi.fn((id: string) => sources.set(id, { setData: vi.fn() })),
getSource: (id: string) => sources.get(id),
addLayer: vi.fn((layer: { id: string; filter?: unknown }) => layers.set(layer.id, { filter: layer.filter })),
getLayer: (id: string) => layers.get(id),
setFilter: vi.fn((id: string, filter: unknown) => layers.set(id, { filter })),
};
return { map: map as unknown as mapboxgl.Map, layers, sources, raw: map };
}
describe('gpxTrackLayers (PR1-08)', () => {
it('adds the rim, the line, the picked track and the hit line once, picking nothing yet', () => {
const { map, layers } = fakeMap();
expect(hasGpxTrackLayers(map)).toBe(false);
addGpxTrackLayers(map);
expect(hasGpxTrackLayers(map)).toBe(true);
expect([...layers.keys()]).toEqual([
'trip-gpx-casing',
'trip-gpx-line',
'trip-gpx-selected-casing',
'trip-gpx-selected',
GPX_HIT_LAYER_ID,
]);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
});
it('puts only places with a drawable track into the source, with their colour and rim', () => {
const { map, sources } = fakeMap();
addGpxTrackLayers(map);
const track = { id: 5, route_geometry: '[[47,11],[47.1,11.1]]', route_color: '#ff0000' } as Place;
const plain = { id: 6, route_geometry: null } as Place;
setGpxTracks(map, [track, plain]);
const data = sources.get('trip-gpx')?.setData.mock.calls[0]?.[0];
expect(data.features).toHaveLength(1);
expect(data.features[0].properties).toEqual({ color: '#ff0000', cased: true, place_id: 5 });
expect(data.features[0].geometry.coordinates).toEqual([
[11, 47],
[11.1, 47.1],
]);
});
it('points the picked layers at the selected place, and at none again when nothing is picked', () => {
const { map, layers } = fakeMap();
addGpxTrackLayers(map);
selectGpxTrack(map, 5);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
expect(layers.get('trip-gpx-selected-casing')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
selectGpxTrack(map, null);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
});
it('does nothing before the style has its layers', () => {
const { map, raw } = fakeMap();
selectGpxTrack(map, 5);
setGpxTracks(map, []);
expect(raw.setFilter).not.toHaveBeenCalled();
});
});
+103
View File
@@ -0,0 +1,103 @@
import { TRACK_COLOR_FALLBACK } from '@trek/shared';
import type mapboxgl from 'mapbox-gl';
import type { Place } from '../../types';
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry';
import { hasManualTrackColor, resolveTrackColor, TRACK_CASING_COLOR } from './trackColors';
const SOURCE_ID = 'trip-gpx';
/** The invisible fat line that takes a click on a track. */
export const GPX_HIT_LAYER_ID = 'trip-gpx-hit';
const SELECTED_CASING_LAYER_ID = 'trip-gpx-selected-casing';
const SELECTED_LAYER_ID = 'trip-gpx-selected';
const ROUND = { 'line-cap': 'round', 'line-join': 'round' } as const;
const NO_TRACK = ['==', ['get', 'place_id'], -1] as mapboxgl.FilterSpecification;
/**
* The tracks of the vector map, as the Leaflet map draws them (TrackLines): a
* rim under the tracks that carry a picked colour, the lines, the picked
* track on top wider with a light rim (PR1-08), and an invisible fat line that
* takes the click. Added once with the style; whoever adds it wires the click.
*/
export function addGpxTrackLayers(map: mapboxgl.Map): void {
map.addSource(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
// Casing under the tracks that carry a picked colour (#776): legible on satellite and dark styles.
// Untouched tracks are filtered out, so they look exactly as they did before.
map.addLayer({
id: 'trip-gpx-casing',
type: 'line',
source: SOURCE_ID,
filter: ['==', ['get', 'cased'], true],
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 6.5, 'line-opacity': 0.7 },
layout: ROUND,
});
map.addLayer({
id: 'trip-gpx-line',
type: 'line',
source: SOURCE_ID,
paint: {
'line-color': ['coalesce', ['get', 'color'], TRACK_COLOR_FALLBACK],
'line-width': 3.5,
'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
},
layout: ROUND,
});
// The filter follows the selection (selectGpxTrack); until something is picked it matches no track.
map.addLayer({
id: SELECTED_CASING_LAYER_ID,
type: 'line',
source: SOURCE_ID,
filter: NO_TRACK,
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 9, 'line-opacity': 0.95 },
layout: ROUND,
});
map.addLayer({
id: SELECTED_LAYER_ID,
type: 'line',
source: SOURCE_ID,
filter: NO_TRACK,
paint: { 'line-color': ['coalesce', ['get', 'color'], TRACK_COLOR_FALLBACK], 'line-width': 5.5 },
layout: ROUND,
});
// 3.5px is not a target, and the start markers cluster below zoom 11, so without this a track
// is unreachable at the very zoom where you compare walks side by side.
map.addLayer({
id: GPX_HIT_LAYER_ID,
type: 'line',
source: SOURCE_ID,
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 14, 'line-opacity': 0 },
layout: ROUND,
});
}
/** Whether the track layers are there yet (they come with the style). */
export function hasGpxTrackLayers(map: mapboxgl.Map): boolean {
return !!map.getSource(SOURCE_ID);
}
/** The places' tracks into the source, each with its colour and whether it carries a rim. */
export function setGpxTracks(map: mapboxgl.Map, places: readonly Place[]): void {
const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
if (!src) return;
const features = places.flatMap((place) => {
const coords = parseRenderableRouteGeometry(place.route_geometry);
if (!coords) return [];
return [
{
type: 'Feature' as const,
properties: { color: resolveTrackColor(place), cased: hasManualTrackColor(place), place_id: place.id },
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
},
];
});
src.setData({ type: 'FeatureCollection', features });
}
/** PR1-08: the picked place's track stands out; none picked, none does. */
export function selectGpxTrack(map: mapboxgl.Map, placeId: number | null): void {
if (typeof map.getLayer !== 'function') return;
const filter = ['==', ['get', 'place_id'], placeId ?? -1] as mapboxgl.FilterSpecification;
for (const id of [SELECTED_CASING_LAYER_ID, SELECTED_LAYER_ID]) {
if (map.getLayer(id)) map.setFilter(id, filter);
}
}
@@ -8,3 +8,11 @@
* exactly that reason — the others were the ones that needed to match it.
*/
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
/** The frosted glass of the map's floating cards (a ferrata's, a paddle obstacle's), so they read as its controls. */
export const MAP_CARD_GLASS = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}
+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.
*
+18 -4
View File
@@ -18,18 +18,32 @@
* both end up as a real DOM node, which is all HTML5 drag needs.
*/
/**
* Start dragging `placeId` towards the day plan, from any row or marker that holds a
* place (a tour is one too). Every source goes through here, so the day plan cannot
* tell them apart.
*/
export function startPlaceDrag(transfer: DataTransfer | null, placeId: number): void {
transfer?.setData('placeId', String(placeId))
if (transfer) transfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(placeId) }
}
/** The drag `startPlaceDrag` began has ended, dropped or not. */
export function endPlaceDrag(): void {
window.__dragData = null
}
/** Wire a marker element up as a drag source for `placeId`. */
export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => void {
el.setAttribute('draggable', 'true')
const onDragStart = (e: DragEvent) => {
e.dataTransfer?.setData('placeId', String(placeId))
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(placeId) }
startPlaceDrag(e.dataTransfer, placeId)
el.classList.add('marker-dragging')
}
const onDragEnd = () => {
window.__dragData = null
endPlaceDrag()
el.classList.remove('marker-dragging')
}
// The map pans on mousedown anywhere on its surface, and a marker sits on that
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
import { calculateRouteWithRetries, ROUTE_RETRY_DELAYS_MS } from './routeRetry';
vi.mock('./RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('./RouteCalculator')>();
return { ...actual, calculateRouteWithLegs: vi.fn() };
});
const WAYPOINTS = [
{ lat: 47.5, lng: 19.04 },
{ lat: 47.49, lng: 19.05 },
];
const ROUTED = {
coordinates: [
[47.5, 19.04],
[47.49, 19.05],
],
legs: [],
} as unknown as Awaited<ReturnType<typeof calculateRouteWithLegs>>;
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(calculateRouteWithLegs).mockReset();
});
afterEach(() => vi.useRealTimers());
describe('calculateRouteWithRetries', () => {
it('waits out a rate limit and answers with the road (Discord, 16.10.2026)', async () => {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
.mockResolvedValueOnce(ROUTED);
const answer = calculateRouteWithRetries(WAYPOINTS, { profile: 'driving' });
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
await expect(answer).resolves.toBe(ROUTED);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2);
});
it('waits as long as the host asks when that is longer', async () => {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(503, 3000))
.mockResolvedValueOnce(ROUTED);
const answer = calculateRouteWithRetries(WAYPOINTS, {});
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(3000 - ROUTE_RETRY_DELAYS_MS[0]);
await expect(answer).resolves.toBe(ROUTED);
});
it('gives up after the last delay and throws the refusal', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null));
const answer = calculateRouteWithRetries(WAYPOINTS, {});
const settled = expect(answer).rejects.toBeInstanceOf(RoutingRefusedError);
for (const ms of ROUTE_RETRY_DELAYS_MS) await vi.advanceTimersByTimeAsync(ms);
await settled;
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(ROUTE_RETRY_DELAYS_MS.length + 1);
});
it('throws a refusal the host meant at once', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(400, null));
await expect(calculateRouteWithRetries(WAYPOINTS, {})).rejects.toBeInstanceOf(RoutingRefusedError);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
});
it('stops waiting when the day changes', async () => {
const controller = new AbortController();
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
const answer = calculateRouteWithRetries(WAYPOINTS, { signal: controller.signal });
const settled = expect(answer).rejects.toMatchObject({ name: 'AbortError' });
await vi.advanceTimersByTimeAsync(10);
controller.abort();
await settled;
});
});
+48
View File
@@ -0,0 +1,48 @@
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
// The profile a caller hands in, so a hook routing through here needs nothing else from Map/.
export type { RouteProfileKey } from './RouteCalculator';
/** How often a rate-limited route is asked for again, and how long after. */
export const ROUTE_RETRY_DELAYS_MS = [1500, 4000];
/** A pause that ends early, without throwing, when the signal aborts. */
export const sleepUnlessAborted = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise((resolve) => {
if (signal?.aborted || ms <= 0) {
resolve();
return;
}
const timer = setTimeout(done, ms);
function done(): void {
clearTimeout(timer);
signal?.removeEventListener('abort', done);
resolve();
}
signal?.addEventListener('abort', done, { once: true });
});
/**
* `calculateRouteWithLegs`, asked again while the host says it is busy.
*
* The public OSRM hosts allow about one request a second and answer 429 above that. A
* day with a change of mode, or a quick switch between days, asks more often than that,
* and the day route used to give up on the first refusal and keep its straight lines
* until the next edit (reported on Discord, 16.10.2026). A rate limit is waited out, for
* as long as the host asks when it says; a refusal the host meant is thrown at once,
* because the same request earns the same answer. An abort ends the wait, and the next
* attempt then throws the AbortError the caller already handles.
*/
export async function calculateRouteWithRetries(
...[waypoints, opts]: Parameters<typeof calculateRouteWithLegs>
): ReturnType<typeof calculateRouteWithLegs> {
for (let attempt = 0; ; attempt++) {
try {
return await calculateRouteWithLegs(waypoints, opts);
} catch (err) {
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null;
if (!rateLimit || attempt >= ROUTE_RETRY_DELAYS_MS.length || opts?.signal?.aborted) throw err;
await sleepUnlessAborted(Math.max(ROUTE_RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), opts?.signal);
}
}
}
+3
View File
@@ -1,6 +1,9 @@
import { TRACK_COLOR_FALLBACK } from '@trek/shared'
import type { Place } from '../../types'
/** The light rim under a track that carries a picked colour, and under the track picked on the map. */
export const TRACK_CASING_COLOR = '#ffffff'
/**
* The colour a track's polyline is drawn in (#776): a manually picked colour
* wins, then the category colour, then the blue every imported track used to
+12 -32
View File
@@ -1,4 +1,5 @@
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
import type { RouteProfileKey } from './RouteCalculator'
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
import { projectDayItinerary } from './dayTourProjection'
@@ -185,20 +186,6 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
const REQUEST_SPACING_MS = 1100
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
const CACHE_HIT_MS = 60
/** How often a rate-limited leg is tried again, and how long after. */
const RETRY_DELAYS_MS = [1500, 4000]
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise(resolve => {
if (signal?.aborted || ms <= 0) { resolve(); return }
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal?.removeEventListener('abort', done)
resolve()
}
signal?.addEventListener('abort', done, { once: true })
})
/**
* Ask the router for every leg of every day, one at a time.
@@ -233,23 +220,16 @@ export async function routeTripLegs(
runs.forEach((chunks, r) => {
chunks.forEach((chunk, c) => {
tasks.push(async () => {
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
if (signal?.aborted) return
try {
const answer = await calculateRouteWithLegs(
chunk.waypoints,
{ signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
onAnswer?.(routed)
return
} catch (err) {
if (signal?.aborted) return
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
// When the host says how long to wait, waiting less is just a second refusal.
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
}
if (signal?.aborted) return
try {
const answer = await calculateRouteWithRetries(
chunk.waypoints,
{ signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
onAnswer?.(routed)
} catch {
// Refused for good, or aborted: the leg keeps its straight line.
}
})
})
@@ -0,0 +1,34 @@
import { useEffect, type RefObject } from 'react';
/**
* Closes a card floating over the map on a press anywhere outside it and on
* Escape, while it is open. The press outside is handed to `onOutsidePress`
* as well, so the map under it can leave the click that follows alone (see
* `useOutsidePressGuard`). A press on another object closes this card first,
* and the click then opens that object's.
*/
export function useMapCardDismiss(
ref: RefObject<HTMLElement | null>,
open: boolean,
onClose: () => void,
onOutsidePress?: () => void
): void {
useEffect(() => {
if (!open) return;
const onDown = (event: PointerEvent) => {
const card = ref.current;
if (card && event.target instanceof Node && card.contains(event.target)) return;
onOutsidePress?.();
onClose();
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !event.defaultPrevented) onClose();
};
document.addEventListener('pointerdown', onDown, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown, true);
document.removeEventListener('keydown', onKey);
};
}, [open, onClose, onOutsidePress, ref]);
}
@@ -271,7 +271,9 @@ describe('PackingListPanel', () => {
];
const { container } = render(<PackingListPanel tripId={1} items={items} />);
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
expect(container.querySelector('[draggable="true"]')).toBeNull();
// Only the category grip stays: the order of the categories is not the A to Z of their items.
const draggable = [...container.querySelectorAll('[draggable="true"]')];
expect(draggable.map((el) => el.getAttribute('aria-label'))).toEqual(['Drag to reorder the category']);
});
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
@@ -4,7 +4,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
Trash2, Plus, ChevronDown, ChevronRight,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, GripVertical,
} from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
@@ -38,6 +38,16 @@ interface KategorieGruppeProps {
// Off while the list is shown A-Z: a drop there would move an item in an order
// the reader cannot see.
reorderable?: boolean
// Moving the whole category among the others, wired by the list, which sees every
// card. `onDrop` gets the category that was dragged onto this one.
categoryDrag?: {
isDragging: boolean
isOver: boolean
onStart: () => void
onOver: () => void
onEnd: () => void
onDrop: (dragged: string) => void
}
// Three-tier sharing (#858) — threaded down to each item's share control.
currentUserId?: number
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
@@ -46,7 +56,7 @@ interface KategorieGruppeProps {
onLeave?: (id: number, userId: number) => void
}
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, categoryDrag, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null)
@@ -120,8 +130,9 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
return (
// Shaped after the phone's category card: a tinted head band carrying the
// name as written, the count, and the fold arrow on the far right.
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
<CategoryDropFrame drag={categoryDrag}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
<CategoryGrip drag={categoryDrag} kategorie={kategorie} label={t('packing.dragCategory')} />
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
{editingName && canEdit ? (
@@ -344,6 +355,65 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
))}
</div>
)}
</CategoryDropFrame>
)
}
type CategoryDrag = NonNullable<KategorieGruppeProps['categoryDrag']>
/** The data type a dragged category carries, its name as the value. */
const CATEGORY_DRAG_TYPE = 'application/x-trek-packing-category'
/**
* The card, and while a category is dragged its drop target. Captured, because
* the item rows stop their own drops from bubbling.
*/
function CategoryDropFrame({ drag, children }: { drag?: CategoryDrag; children: React.ReactNode }) {
// Told apart by the drag's own data type, which is there from the first event on:
// a flag set in state on dragstart can still be unrendered when a quick drag
// arrives, and a dragged item must pass through to its rows untouched.
const isCategoryDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(CATEGORY_DRAG_TYPE)
return (
<div
onDragOverCapture={drag ? (e => {
if (!isCategoryDrag(e)) return
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag.onOver()
}) : undefined}
onDropCapture={drag ? (e => {
if (!isCategoryDrag(e)) return
e.preventDefault(); e.stopPropagation(); drag.onDrop(e.dataTransfer.getData(CATEGORY_DRAG_TYPE))
}) : undefined}
style={{
marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible',
opacity: drag?.isDragging ? 0.4 : 1,
boxShadow: drag?.isOver ? '0 0 0 2px var(--accent)' : 'none',
transition: 'opacity 0.15s, box-shadow 0.15s',
}}
>
{children}
</div>
)
}
/** The handle a category is dragged by, in the head band before its colour dot. */
function CategoryGrip({ drag, kategorie, label }: { drag?: CategoryDrag; kategorie: string; label: string }) {
if (!drag) return null
return (
<span
draggable
role="button"
aria-label={label}
title={label}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(CATEGORY_DRAG_TYPE, kategorie)
e.dataTransfer.setData('text/plain', kategorie)
drag.onStart()
}}
onDragEnd={drag.onEnd}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginInlineStart: -6 }}
>
<GripVertical size={14} />
</span>
)
}
@@ -1,3 +1,4 @@
import { useState } from 'react'
import type { PackingState } from './usePackingListPanel'
import { KategorieGruppe } from './PackingListPanelCategoryGroup'
import EmptyState from '../shared/EmptyState'
@@ -7,8 +8,13 @@ export function PackingList(S: PackingState) {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort,
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, moveCategory,
} = S
// Which category is being dragged and which card it is over. Held here, above
// the cards, because the drag starts in one card and ends in another.
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
const [overCategory, setOverCategory] = useState<string | null>(null)
const endCategoryDrag = () => { setDraggedCategory(null); setOverCategory(null) }
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
{items.length === 0 ? (
@@ -40,6 +46,14 @@ export function PackingList(S: PackingState) {
allItems={items}
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
reorderable={sort === 'manual'}
categoryDrag={canEdit ? {
isDragging: draggedCategory === kat,
isOver: overCategory === kat && draggedCategory !== null && draggedCategory !== kat,
onStart: () => { setDraggedCategory(kat); setOverCategory(null) },
onOver: () => setOverCategory(kat),
onEnd: endCategoryDrag,
onDrop: (dragged) => { moveCategory(dragged, kat); endCategoryDrag() },
} : undefined}
currentUserId={currentUserId}
onSetSharing={handleSetSharing}
onClone={handleCloneItem}
@@ -6,6 +6,7 @@ import {
groupPackingItems,
isLastCustomItemInCategory,
isPackingPlaceholder,
itemOrderWithCategoryMoved,
packingCategoryOrder,
packingItemWeight,
packingProgress,
@@ -14,7 +15,7 @@ import {
} from './packingListModel';
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
// FE-PACK-MODEL-001 to FE-PACK-MODEL-008
// FE-PACK-MODEL-001 to FE-PACK-MODEL-009
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
@@ -76,4 +77,28 @@ describe('packingListModel', () => {
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
});
it('FE-PACK-MODEL-009: moving a category moves its items as a block, the other view stays as it was', () => {
const day1 = buildPackingItem({ id: 11, name: 'Shirt', category: 'Day 1' });
const day2a = buildPackingItem({ id: 12, name: 'Shorts', category: 'Day 2' });
const own = buildPackingItem({ id: 13, name: 'Pills', category: 'Day 2', is_private: 1 });
const day3 = buildPackingItem({ id: 14, name: 'Jacket', category: 'Day 3' });
const day2b = buildPackingItem({ id: 15, name: 'Hat', category: 'Day 2' });
const items = [day1, day2a, own, day3, day2b];
const common = packingViewItems(items, 'common');
const order = itemOrderWithCategoryMoved(items, common, 'Day 3', 'Day 1', 'Other');
expect(order).toEqual([14, 11, 12, 15, 13]);
const moved = (order ?? []).flatMap((id) => items.filter((i) => i.id === id));
expect(packingCategoryOrder(packingViewItems(moved, 'common'), 'Other')).toEqual(['Day 3', 'Day 1', 'Day 2']);
expect(packingCategoryOrder(packingViewItems(moved, 'personal'), 'Other')).toEqual(['Day 2']);
expect(itemOrderWithCategoryMoved(items, common, 'Day 1', 'Day 3', 'Other')).toEqual([12, 15, 14, 11, 13]);
});
it('FE-PACK-MODEL-009b: nothing moves for the same category or one the view does not show', () => {
const items = [shared, stove, loose];
expect(itemOrderWithCategoryMoved(items, items, 'Camp', 'Camp', 'Other')).toBeNull();
expect(itemOrderWithCategoryMoved(items, items, 'Clothes', 'Camp', 'Other')).toBeNull();
expect(itemOrderWithCategoryMoved(items, items, 'Other', 'Camp', 'Other')).toEqual([4, 1, 3]);
});
});
@@ -25,6 +25,34 @@ export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory:
return seen;
}
/**
* The whole list's item order with category `from` moved to where `to` stands.
*
* A category has no position of its own: it shows where its first item is. So a
* category is moved by moving its items, as one block, through the item reorder
* the list already has; nothing new is stored, and the order reaches every device
* the way a dragged item does. The view's items come first, grouped in the new
* order with each group in its own order; the other view's items follow in theirs,
* which leaves that view's categories as they were. Null when nothing would move.
*/
export function itemOrderWithCategoryMoved(
items: PackingItem[],
viewItems: PackingItem[],
from: string,
to: string,
defaultCategory: string
): number[] | null {
const order = packingCategoryOrder(viewItems, defaultCategory);
const fromIndex = order.indexOf(from);
const toIndex = order.indexOf(to);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
order.splice(fromIndex, 1);
order.splice(toIndex, 0, from);
const inView = new Set(viewItems.map((i) => i.id));
const grouped = order.flatMap((cat) => viewItems.filter((i) => (i.category || defaultCategory) === cat));
return [...grouped, ...items.filter((i) => !inView.has(i.id))].map((i) => i.id);
}
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
if (status === 'open') return items.filter((i) => !i.checked);
if (status === 'done') return items.filter((i) => !!i.checked);
@@ -8,7 +8,7 @@ import { tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import type { PackingItem } from '../../types'
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
import { usePackingBags } from './usePackingBags'
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
import { usePackingImport } from './usePackingImport'
@@ -106,6 +106,13 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
// A category dropped onto another takes its place; the slice reorders
// optimistically and rolls back with a toast of its own.
const moveCategory = (from: string, to: string) => {
const orderedIds = itemOrderWithCategoryMoved(items, viewItems, from, to, t('packing.defaultCategory'))
if (orderedIds) void reorderPackingItems(tripId, orderedIds)
}
const {
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
@@ -213,7 +220,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, moveCategory,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
@@ -2,10 +2,12 @@ import type { TourListItem } from '@trek/shared';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTranslation } from '../../i18n';
import '../../styles/fadeEnd.css';
import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import TourDayRowFacts from '../Tours/TourDayRowFacts';
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
import { movingTimeLabel } from '../Tours/tourFacts';
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
import { TimePill } from './planParts';
@@ -34,9 +36,10 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
<TimePill>
<span
data-testid="trip-plan-tour-walking-time"
aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
aria-label={`${movingTimeLabel(tourTimes.tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
>
{t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
{movingTimeLabel(tourTimes.tour, t('tours.planner.walkingShort'), t)}:{' '}
{formatPlannedTourDuration(tourTimes.walkingMinutes)}
</span>
</TimePill>
)}
@@ -65,7 +68,7 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
);
}
// Description line; tour stops get two clamped lines instead of one.
// Description line, one line like every stop's.
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
return (
<div
@@ -75,10 +78,9 @@ function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace:
...fs(10.5),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: isTourPlace ? 'normal' : 'nowrap',
whiteSpace: 'nowrap',
lineHeight: 1.3,
maxHeight: isTourPlace ? '2.6em' : '1.3em',
...(isTourPlace ? { display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const } : {}),
maxHeight: '1.3em',
}}
>
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
@@ -94,14 +96,16 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
const description = place.description || place.address || cat?.name;
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
return (
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5">
// One line like the row's name: when the hover controls come out, the description
// gives way and the pills keep their place instead of wrapping onto a second line.
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
{place.place_time && (
<TimePill quiet>
{formatTime(place.place_time, locale, timeFormat)}
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</TimePill>
)}
{dayRowTour ? <TourDayRowFacts tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
</div>
);
@@ -155,7 +155,7 @@ beforeEach(() => {
});
describe('DayPlanSidebar', () => {
it('RS-01: previews Tour descriptions in two lines only while Tours is enabled', () => {
it('RS-01: previews a Tour description on one line like every stop, marked only while Tours is enabled', () => {
const description = 'A long description of the ridge route, the lake overlook, and the return path.';
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
@@ -172,9 +172,9 @@ describe('DayPlanSidebar', () => {
const view = render(<DayPlanSidebar {...props} />);
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement;
expect(tourPreview).toHaveTextContent(description);
expect(tourPreview.style.display).toBe('-webkit-box');
expect(tourPreview.style.webkitLineClamp).toBe('2');
expect(tourPreview.style.maxHeight).toBe('2.6em');
// One line like the row's name, so hover controls make the description give way instead of wrapping.
expect(tourPreview).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' });
expect(tourPreview.parentElement).toHaveClass('flex-nowrap', 'overflow-hidden');
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h');
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min');
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min');
@@ -231,14 +231,77 @@ describe('DayPlanSidebar', () => {
});
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
const view = render(<DayPlanSidebar {...props} />);
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
// The tile carries the grade, as in the tour lists; the facts sit in the plan's white badges.
expect(screen.getByTestId('plan-tour-icon')).toHaveTextContent('T2');
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100');
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min');
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
const time = screen.getByTestId('trip-plan-tour-time');
expect(time).toHaveTextContent('1 h 3 min');
expect(time).toHaveAccessibleName(
'Walking time: 53 min, Breaks / additional time: 10 min, Planned total duration: 1 h 3 min'
);
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
view.rerender(<DayPlanSidebar {...props} />);
expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument();
});
it('RC-01: an imported tour without a grade shows its type on the tile', () => {
const place = buildPlace({ id: 46, name: 'Recorded loop', tour_place_id: 46 });
const day = buildDay({ id: 10, title: 'Day 1' });
const assignment = buildAssignment({ id: 102, day_id: 10, order_index: 0, place });
const tour = {
place_id: 46,
name: place.name,
tour_type: 'hike' as const,
distance: 6,
elevation_gain: 300,
elevation_loss: 300,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: true,
caution: false,
has_waypoints: false,
};
const props = makeDefaultProps({
days: [day],
places: [place],
tourPlaceIds: new Set([46]),
assignments: { '10': [assignment] },
plannedTourDurations: new Map([
[46, { tour, walkingMinutes: null, breakMinutes: null, plannedTotalMinutes: null, manuallyOverridden: false }],
]),
});
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
render(<DayPlanSidebar {...props} />);
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
expect(screen.queryByTestId('trip-plan-tour-time')).not.toBeInTheDocument();
});
it('RC-01: a tour row offers no booking, a tour is not booked', () => {
const place = buildPlace({ id: 45, name: 'Ridge walk', tour_place_id: 45 });
const cafe = buildPlace({ id: 47, name: 'Lakeside cafe' });
const day = buildDay({ id: 10, title: 'Day 1' });
const assignments = {
'10': [
buildAssignment({ id: 101, day_id: 10, order_index: 0, place }),
buildAssignment({ id: 103, day_id: 10, order_index: 1, place: cafe }),
],
};
const props = makeDefaultProps({
days: [day],
places: [place, cafe],
tourPlaceIds: new Set([45]),
assignments,
onAddBookingToAssignment: vi.fn(),
});
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
render(<DayPlanSidebar {...props} />);
// The cafe can be booked; the tour beside it cannot.
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
});
});
@@ -83,7 +83,7 @@ import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import type { TourPlannedTimes } from '../Tours/tourPresentation'
import type { TourListItem } from '@trek/shared'
import { TourDayRowIcon } from '../Tours/TourDayRowFacts'
import { TourDayRowAvatar } from '../Tours/TourDayRowFacts'
import { DayPlanPlaceSubline } from './DayPlanPlaceSubline'
export interface DayPlanSidebarProps {
@@ -2000,7 +2000,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onMouseLeave={() => setLockHoverId(null)}
className="relative flex flex-none rounded-full"
>
{dayRowTour ? <TourDayRowIcon type={dayRowTour.tour_type} size={32} /> : <PlaceAvatar place={place} category={cat} size={32} />}
{dayRowTour ? <TourDayRowAvatar tour={dayRowTour} size={32} /> : <PlaceAvatar place={place} category={cat} size={32} />}
{/* Hover/locked overlay */}
{(lockHoverId === assignment.id || isLocked) && (
<span className="absolute inset-0 grid place-items-center rounded-full transition-colors"
@@ -2141,7 +2141,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>}
{canEditDays && onAddBookingToAssignment && (
{canEditDays && onAddBookingToAssignment && !isTourPlace && (
<Tooltip label={t('reservations.addBooking')} placement="top">
<button type="button" aria-label={t('reservations.addBooking')}
onClick={e => {
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { resolveTrackColor } from '../Map/trackColors'
import { MoreButton, SoftPill, type MenuEntry } from './planParts'
import type { Place, Category } from '../../types'
import { startPlaceDrag } from '../Map/markerDrag'
interface MemoPlaceRowProps {
place: Place
@@ -73,9 +74,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
draggable={!selectMode && !dragDisabled}
onDragStart={e => {
if (dragDisabled) { e.preventDefault(); return }
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(place.id) }
startPlaceDrag(e.dataTransfer, place.id)
}}
onClick={activate}
onKeyDown={e => {
@@ -308,9 +308,10 @@ export default function AccountTab(): React.ReactElement {
/>
<input
type="text"
inputMode="numeric"
inputMode="text"
autoComplete="one-time-code"
value={mfaDisableCode}
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
placeholder={t('settings.mfa.codePlaceholder')}
aria-label={t('settings.mfa.codePlaceholder')}
className={`${INPUT} font-geist tabular-nums`}
@@ -16,6 +16,7 @@ import { EditorField, INPUT, Segmented, TEXTAREA } from '../shared/dialogParts'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
import { useIntegrationGates } from './useIntegrationGates'
import WandererConnectionSection from './WandererConnectionSection'
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
@@ -28,6 +29,7 @@ export default function IntegrationsTab(): React.ReactElement {
{!managed && <PhotoProvidersSection />}
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
{S.wandererEnabled && !managed && <WandererConnectionSection />}
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
@@ -45,11 +47,14 @@ export default function IntegrationsTab(): React.ReactElement {
function useIntegrations() {
const { t, locale } = useTranslation()
const toast = useToast()
const { mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const { mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed, ...mcp }
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled,
wandererEnabled,
managed, ...mcp,
}
}
type IntegrationsState = ReturnType<typeof useIntegrations>
@@ -0,0 +1,131 @@
import userEvent from '@testing-library/user-event';
import type { WandererConnection } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import WandererConnectionSection from './WandererConnectionSection';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const disconnected: WandererConnection = {
url: '',
tokenMasked: '',
allowInsecureTls: false,
connected: false,
lastCheckedAt: null,
lastCheckOk: null,
lastCheckError: null,
};
const connected: WandererConnection = {
...disconnected,
url: 'https://wanderer.test',
tokenMasked: '••••••••',
connected: true,
};
function settingsRoute(state: { current: typeof disconnected }) {
return http.get('/api/integrations/wanderer/settings', () => HttpResponse.json(state.current));
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
});
describe('WandererConnectionSection', () => {
it('RS-03: saves an address and a token, and never fills the token back in', async () => {
const state = { current: disconnected };
const saved: unknown[] = [];
server.use(
settingsRoute(state),
http.put('/api/integrations/wanderer/settings', async ({ request }) => {
saved.push(await request.json());
state.current = connected;
return HttpResponse.json({ success: true });
})
);
render(<WandererConnectionSection />);
expect(await screen.findByText('Not connected')).toBeInTheDocument();
await userEvent.type(screen.getByLabelText('Instance URL'), 'https://wanderer.test');
await userEvent.type(screen.getByLabelText('API token'), 'wanderer_key_abc');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(saved).toEqual([{ url: 'https://wanderer.test', token: 'wanderer_key_abc', allowInsecureTls: false }])
);
expect(await screen.findByText('Connected')).toBeInTheDocument();
expect(screen.getByLabelText('API token')).toHaveValue('');
expect(toast.success).toHaveBeenCalled();
});
it('RS-03: says a token that is not a wanderer API token before sending it', async () => {
server.use(settingsRoute({ current: disconnected }));
render(<WandererConnectionSection />);
await userEvent.type(await screen.findByLabelText('Instance URL'), 'https://wanderer.test');
await userEvent.type(screen.getByLabelText('API token'), 'my-password');
expect(screen.getByLabelText('API token')).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});
it('RS-03: a test names how many own trails there are, or why it failed', async () => {
const answers = [
{ ok: true, trailCount: 5 },
{ ok: false, code: 'unauthorized' },
];
server.use(
settingsRoute({ current: connected }),
http.post('/api/integrations/wanderer/test', () => HttpResponse.json(answers.shift()))
);
render(<WandererConnectionSection />);
await screen.findByText('Connected');
await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
expect(await screen.findByText('Connected. You can import 5 of your trails.')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
expect(await screen.findByRole('status')).not.toHaveTextContent('Connected. You can');
});
it('RS-03: shows what the last check said when it failed', async () => {
server.use(
settingsRoute({
current: { ...connected, lastCheckedAt: '2026-10-15 10:00:00', lastCheckOk: false, lastCheckError: 'disabled' },
})
);
render(<WandererConnectionSection />);
expect(await screen.findByText('Your admin has not enabled wanderer for Tours.')).toBeInTheDocument();
});
it('RS-03: disconnecting forgets the connection', async () => {
const state = { current: connected };
server.use(
settingsRoute(state),
http.delete('/api/integrations/wanderer/settings', () => {
state.current = disconnected;
return HttpResponse.json({ success: true });
})
);
render(<WandererConnectionSection />);
await userEvent.click(await screen.findByRole('button', { name: 'Disconnect' }));
expect(await screen.findByText('Not connected')).toBeInTheDocument();
});
it('RS-03: a refused save is said as its reason', async () => {
server.use(
settingsRoute({ current: disconnected }),
http.put('/api/integrations/wanderer/settings', () =>
HttpResponse.json({ error: 'x', code: 'blocked_address' }, { status: 400 })
)
);
render(<WandererConnectionSection />);
await userEvent.type(await screen.findByLabelText('Instance URL'), 'http://10.0.0.5');
await userEvent.type(screen.getByLabelText('API token'), 'wanderer_key_abc');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
});
});
@@ -0,0 +1,141 @@
import { Loader2, Plug, Save, Unplug } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../i18n';
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts';
import { fs } from '../shared/DialogShell';
import WandererMark from '../Tours/wanderer/WandererMark';
import Section from './Section';
import {
SETTINGS_BUTTON,
SETTINGS_BUTTON_DANGER,
SETTINGS_BUTTON_PRIMARY,
SettingRow,
SettingRows,
SettingsHint,
StatusPill,
} from './settingsKit';
import ToggleSwitch from './ToggleSwitch';
import { useWandererConnection } from './useWandererConnection';
/**
* Settings → Integrations → wanderer: the reader's own trail database, from
* which the Tours workspace imports trails. Laid out like the Dawarich and
* AirTrail cards beside it; the behaviour is in `useWandererConnection`.
*/
export default function WandererConnectionSection(): React.ReactElement {
const { t } = useTranslation();
const S = useWandererConnection();
return (
<Section
title={t('wanderer.title')}
icon={WandererMark}
badge={
<StatusPill
tone={S.connected ? 'success' : 'neutral'}
icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}
>
{S.connected ? t('wanderer.connected') : t('wanderer.notConnected')}
</StatusPill>
}
>
<SettingsHint>{t('wanderer.intro')}</SettingsHint>
<div className={GRID_2}>
<EditorField label={t('wanderer.url')} htmlFor="wanderer-url">
<input
id="wanderer-url"
type="url"
value={S.url}
onChange={(event) => S.setUrl(event.target.value)}
placeholder="https://wanderer.example.com"
className={INPUT}
/>
</EditorField>
<EditorField
label={t('wanderer.token')}
htmlFor="wanderer-token"
hint={S.tokenInvalid ? undefined : t('wanderer.tokenHint')}
>
<input
id="wanderer-token"
type="password"
value={S.token}
onChange={(event) => S.setToken(event.target.value)}
autoComplete="off"
aria-invalid={S.tokenInvalid || undefined}
placeholder={S.connected && !S.token ? '••••••••' : 'wanderer_key_…'}
className={INPUT}
/>
{S.tokenInvalid && (
<p className="m-0 mt-1 text-danger" style={fs(11.5, 'body')}>
{t('wanderer.tokenInvalid')}
</p>
)}
</EditorField>
</div>
<SettingRows>
<SettingRow
label={t('wanderer.allowInsecureTls')}
hint={t('wanderer.allowInsecureTlsHint')}
control={
<ToggleSwitch
on={S.allowInsecureTls}
onToggle={S.toggleInsecureTls}
label={t('wanderer.allowInsecureTls')}
/>
}
/>
</SettingRows>
<div className="flex flex-wrap items-center gap-2" style={fs(13, 'body')}>
<button
type="button"
onClick={() => void S.save()}
disabled={S.saving || S.loading || !S.canSave}
className={SETTINGS_BUTTON_PRIMARY}
>
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
</button>
<button
type="button"
onClick={() => void S.test()}
disabled={S.testing || S.loading || !S.url.trim() || S.tokenInvalid}
className={SETTINGS_BUTTON}
>
{S.testing ? (
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
) : (
<Plug size={14} strokeWidth={2.2} />
)}
{t('wanderer.test.button')}
</button>
{S.connected && (
<button
type="button"
onClick={() => void S.disconnect()}
disabled={S.saving}
className={`${SETTINGS_BUTTON_DANGER} ms-auto`}
>
<Unplug size={14} strokeWidth={2.2} /> {t('wanderer.disconnect')}
</button>
)}
</div>
{(S.probe || S.lastCheck) && (
<div
role="status"
className="flex flex-col gap-1 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3"
style={fs(12, 'body')}
>
{S.probe ? (
<p className={`m-0 font-medium ${S.probe.ok ? 'text-success' : 'text-danger'}`}>{S.probe.message}</p>
) : (
<p className="m-0 text-danger">{S.lastCheck}</p>
)}
</div>
)}
</Section>
);
}
@@ -52,4 +52,11 @@ describe('useIntegrationGates', () => {
renderHook(() => useIntegrationGates({ reloadAddons: true }));
expect(loud).toHaveBeenCalledTimes(1);
});
it('RS-03: offers the wanderer connection only while the admin allows wanderer under Tours', () => {
seedAddons(['tours']);
useAddonStore.setState({ toursWanderer: false });
expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.wandererEnabled).toBe(false);
useAddonStore.setState({ toursWanderer: true });
expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.wandererEnabled).toBe(true);
});
});
@@ -19,6 +19,8 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean })
const airtrailEnabled = addonEnabled('airtrail');
const llmEnabled = addonEnabled('llm_parsing');
const dawarichEnabled = addonEnabled('dawarich');
// The wanderer connection feeds the Tours workspace, while the admin allows it there.
const wandererEnabled = useAddonStore((s) => s.toursWanderer);
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || dawarichEnabled;
useEffect(() => {
@@ -31,6 +33,7 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean })
airtrailEnabled,
llmEnabled,
dawarichEnabled,
wandererEnabled,
hasIntegrations,
managed,
loadAddons,
@@ -0,0 +1,152 @@
import {
WANDERER_TOKEN_MASK,
WANDERER_TOKEN_PREFIX,
type WandererConnection,
type WandererTestResponse,
} from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import { wandererErrorCode, wandererRepo } from '../../repo/wandererRepo';
import { useToast } from '../shared/Toast';
import { wandererErrorText } from '../Tours/wanderer/wandererErrors';
/** What the last test found, said in the card; null before a test. */
export interface WandererProbe {
ok: boolean;
message: string;
}
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** What a test answered, as the line the card shows under its buttons. */
function probeOf(result: WandererTestResponse, t: Translate): WandererProbe {
if (!result.ok) return { ok: false, message: wandererErrorText(t, result.code ?? 'unknown') };
const message =
result.trailCount != null ? t('wanderer.test.okCount', { count: result.trailCount }) : t('wanderer.test.ok');
return { ok: true, message };
}
/**
* The wanderer connection card's logic: the form, saving, testing and
* disconnecting, with every failure turned into a sentence the reader can act
* on. The token field is never filled from the server; blank keeps the stored one.
*/
export function useWandererConnection() {
const { t } = useTranslation();
const toast = useToast();
const [url, setUrl] = useState('');
const [token, setToken] = useState('');
const [allowInsecureTls, setAllowInsecureTls] = useState(false);
const [saved, setSaved] = useState<WandererConnection | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [probe, setProbe] = useState<WandererProbe | null>(null);
const errorText = useCallback((code: string) => wandererErrorText(t, code), [t]);
const apply = useCallback((connection: WandererConnection) => {
setSaved(connection);
setUrl(connection.url);
setAllowInsecureTls(connection.allowInsecureTls);
setToken('');
}, []);
useEffect(() => {
let cancelled = false;
wandererRepo
.getSettings()
.then((connection) => {
if (!cancelled) apply(connection);
})
.catch(() => {
if (!cancelled) toast.error(t('wanderer.loadFailed'));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [apply, t, toast]);
const typedToken = token.trim();
/** A typed token that wanderer would read as no token at all, said before it is sent. */
const tokenInvalid =
typedToken !== '' && typedToken !== WANDERER_TOKEN_MASK && !typedToken.startsWith(WANDERER_TOKEN_PREFIX);
const connected = !!saved?.connected;
const canSave = !!url.trim() && !tokenInvalid && (connected || typedToken !== '');
const save = useCallback(async () => {
if (!canSave) return;
setSaving(true);
try {
const result = await wandererRepo.saveSettings({
url: url.trim(),
token: typedToken || undefined,
allowInsecureTls,
});
apply(await wandererRepo.getSettings());
setProbe(null);
toast.success(t('wanderer.saved'));
if (result.warning === 'private_ip') toast.info(t('wanderer.privateIp'));
} catch (error: unknown) {
toast.error(errorText(wandererErrorCode(error)));
} finally {
setSaving(false);
}
}, [allowInsecureTls, apply, canSave, errorText, t, toast, typedToken, url]);
const test = useCallback(async () => {
if (!url.trim() || tokenInvalid) return;
setTesting(true);
try {
const result = await wandererRepo.test({ url: url.trim(), token: typedToken || undefined, allowInsecureTls });
setProbe(probeOf(result, t));
if (!typedToken) setSaved(await wandererRepo.getSettings());
} catch (error: unknown) {
setProbe({ ok: false, message: errorText(wandererErrorCode(error)) });
} finally {
setTesting(false);
}
}, [allowInsecureTls, errorText, t, tokenInvalid, typedToken, url]);
const disconnect = useCallback(async () => {
setSaving(true);
try {
await wandererRepo.disconnect();
apply(await wandererRepo.getSettings());
setProbe(null);
toast.success(t('wanderer.disconnected'));
} catch (error: unknown) {
toast.error(errorText(wandererErrorCode(error)));
} finally {
setSaving(false);
}
}, [apply, errorText, t, toast]);
const lastCheck =
saved?.lastCheckedAt && saved.lastCheckOk === false && saved.lastCheckError
? errorText(saved.lastCheckError)
: null;
return {
url,
setUrl,
token,
setToken,
allowInsecureTls,
toggleInsecureTls: () => setAllowInsecureTls((value) => !value),
connected,
loading,
saving,
testing,
probe,
tokenInvalid,
canSave,
lastCheck,
save,
test,
disconnect,
};
}
@@ -1,8 +1,9 @@
import type { TourListItem } from '@trek/shared';
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest';
import { paddleBench } from '../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import TourDayRowFacts, { TourDayRowIcon } from './TourDayRowFacts';
import TourDayRowFacts, { TourDayRowIcon, TourDayRowPills } from './TourDayRowFacts';
import { tourDayRowIcon } from './tourDayRowIcon';
const tour: TourListItem = {
@@ -74,3 +75,72 @@ describe('RC-01 Tour day-row facts', () => {
expect(openRow).not.toHaveBeenCalled();
});
});
describe('PAD-09 the check bench on a tour row', () => {
const kayak: TourListItem = { ...tour, tour_type: 'kayak', paddle_check: paddleBench() };
it('PCD-001: a kayak tour with a bench shows one checklist pill, its tooltip saying it is no clearance', () => {
render(<TourDayRowPills tour={kayak} />);
const pill = screen.getByTestId('plan-tour-paddle-check');
expect(pill).toHaveTextContent('1 open');
expect(pill.getAttribute('aria-label')).toContain('A checklist is no safety or legal clearance.');
});
it('PCD-002: an area without obstacle data shows no pill at all, never a count', () => {
render(<TourDayRowPills tour={{ ...kayak, paddle_check: paddleBench({ data: 'none', open: 3 }) }} />);
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
});
it('PCD-002b: data that could not be read still shows, without a count', () => {
render(<TourDayRowPills tour={{ ...kayak, paddle_check: paddleBench({ data: 'unavailable', open: 3 }) }} />);
expect(screen.getByTestId('plan-tour-paddle-check')).not.toHaveTextContent('3');
});
it.each([false, true])('PCD-003: the day plan facts name the bench as one more fact (mobile=%s)', (mobile) => {
render(<TourDayRowFacts tour={kayak} mobile={mobile} />);
expect(screen.getByTestId('plan-tour-paddle-check')).toHaveAccessibleName('Checklist: 1 open');
});
const without: Array<[string, TourListItem]> = [
['a hike with a bench', { ...tour, paddle_check: paddleBench() }],
['a kayak tour without a bench', { ...tour, tour_type: 'kayak' }],
['a kayak tour whose bench is null', { ...tour, tour_type: 'kayak', paddle_check: null }],
];
it.each(without)('PCD-004: %s shows no checklist pill and no checklist fact', (_case, item) => {
render(
<>
<TourDayRowPills tour={item} />
<TourDayRowFacts tour={item} />
</>
);
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
});
});
describe('the compact facts of a tour list row', () => {
const testIds = () =>
[...document.querySelectorAll('[data-testid^="plan-tour"], [data-testid="trip-plan-tour-time"]')].map((el) =>
el.getAttribute('data-testid')
);
const timed = { ...tour, distance: 4.2, elevation_gain: 478, planned_duration_minutes: 50 };
it('PCD-005: a hike keeps time, distance and climbing, in that order', () => {
render(<TourDayRowPills tour={timed} compact />);
expect(testIds()).toEqual(['trip-plan-tour-time', 'plan-tour-distance', 'plan-tour-ascent']);
});
it('PCD-006: a paddle tour puts its check bench second and leaves out the climbing', () => {
render(<TourDayRowPills tour={{ ...timed, tour_type: 'kayak', paddle_check: paddleBench() }} compact />);
expect(testIds()).toEqual(['trip-plan-tour-time', 'plan-tour-paddle-check', 'plan-tour-distance']);
});
it('PCD-007: the day plan still shows every fact', () => {
render(<TourDayRowPills tour={{ ...timed, tour_type: 'kayak', paddle_check: paddleBench() }} />);
expect(testIds()).toEqual([
'trip-plan-tour-time',
'plan-tour-distance',
'plan-tour-ascent',
'plan-tour-paddle-check',
]);
});
});
+141 -3
View File
@@ -1,11 +1,15 @@
import type { TourListItem } from '@trek/shared';
import { ArrowUp, Clock, Footprints, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { ArrowUp, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { createElement, useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { WhiteBadge } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { paddleCheckFact } from './paddle/paddleCheckModel';
import { tourDayRowIcon } from './tourDayRowIcon';
import { movingTimeLabel } from './tourFacts';
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
@@ -73,6 +77,138 @@ function Fact({
);
}
/**
* A tour's tile in the day plan and in its detail: the T-scale grade in a round
* tile, the same tile the tour lists use, so a tour reads alike wherever it
* shows. A GPX import has no grade and shows its type's icon instead.
*/
export function TourDayRowAvatar({
tour,
size = 32,
}: {
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
size?: number;
}) {
const { t } = useTranslation();
const level = tour.max_hiking_difficulty;
const known = ['hike', 'bike', 'walk', 'kayak'].includes(tour.tour_type);
const label =
level != null
? t(`tours.planner.difficulty.t${level}`)
: known
? t(`tourTypes.${tour.tour_type}`)
: t('tours.mode.tours');
return (
<span
data-testid="plan-tour-icon"
role="img"
aria-label={label}
className="grid flex-none place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content"
style={{ width: size, height: size, ...fs(size >= 44 ? 17 : 12, 'body') }}
>
{level != null
? `T${level}`
: createElement(tourDayRowIcon(tour.tour_type), {
size: Math.round(size * 0.46),
strokeWidth: 1.9,
className: 'text-content-secondary',
'aria-hidden': 'true',
})}
</span>
);
}
/** One fact of a tour as the white badge a place's time sits in, named by its tooltip. */
export function TourDayRowPill({
icon: Icon,
value,
label,
testId,
}: {
icon: LucideIcon;
value: string;
label: string;
testId: string;
}) {
return (
<Tooltip label={label}>
<span data-testid={testId} aria-label={label} className="inline-flex">
<WhiteBadge icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{value}
</WhiteBadge>
</span>
</Tooltip>
);
}
/**
* A paddle tour's check bench as one fact (PAD-09), or null for a hike, a tour
* listed without one, or an area without obstacle data: a row has no room for
* a badge that says nothing, and the checklist itself still names the gap.
*/
function paddleCheckOf(tour: TourListItem, t: Parameters<typeof paddleCheckFact>[1]) {
const check = tour.tour_type === 'kayak' ? tour.paddle_check : null;
return check && check.data !== 'none' ? paddleCheckFact(check, t) : null;
}
/**
* A tour's facts on its day-plan row, in the badges every other stop uses: the
* time it takes (the planned total where there is one, otherwise the walking
* time, the parts in the tooltip), how far, how much climbing and, for a paddle
* tour, its check bench. `compact` (the tour list) keeps the three that matter
* most, in that order: the time, then a paddle tour's check bench and its
* distance, or a hike's distance and climbing.
*/
export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem; compact?: boolean }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
const times = tourPlannedTimes(tour);
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes;
const timeParts = [
times.walkingMinutes != null
? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}`
: null,
times.breakMinutes
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
: null,
times.plannedTotalMinutes != null
? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
: null,
].filter((part): part is string => part !== null);
const check = paddleCheckOf(tour, t);
const time = shownMinutes != null && (
<TourDayRowPill
key="time"
icon={Clock}
value={formatPlannedTourDuration(shownMinutes)}
label={timeParts.join(', ')}
testId="trip-plan-tour-time"
/>
);
const distance = tour.distance != null && (
<TourDayRowPill
key="distance"
icon={Ruler}
value={formatDistance(tour.distance, unit)}
label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`}
testId="plan-tour-distance"
/>
);
const ascent = tour.elevation_gain != null && (
<TourDayRowPill
key="ascent"
icon={ArrowUp}
value={formatElevation(tour.elevation_gain, unit)}
label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}
testId="plan-tour-ascent"
/>
);
const bench = check && <TourDayRowPill key="check" {...check} testId="plan-tour-paddle-check" />;
if (!compact) return <>{[time, distance, ascent, bench]}</>;
// A paddle tour's climbing is the river's fall, nothing to plan by; its check bench is.
return <>{tour.tour_type === 'kayak' ? [time, bench, distance] : [time, distance, ascent]}</>;
}
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
@@ -86,9 +222,9 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
if (times.walkingMinutes != null)
add(
Footprints,
tourDayRowIcon(tour.tour_type === 'kayak' ? 'kayak' : 'walk'),
formatPlannedTourDuration(times.walkingMinutes),
t('tours.planner.inspector.duration'),
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
'trip-plan-tour-walking-time'
);
if (times.breakMinutes != null && times.breakMinutes > 0)
@@ -105,6 +241,8 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
`${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
'trip-plan-tour-planned-duration'
);
const check = paddleCheckOf(tour, t);
if (check) add(check.icon, check.value, t('tours.paddle.check.title'), 'plan-tour-paddle-check');
return (
<span data-testid="plan-tour-facts" className="flex min-w-0 flex-wrap items-center gap-1">
{facts.map((fact) => (
@@ -1,11 +1,13 @@
import React, { useState } from 'react'
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { buildPlace } from '../../../tests/helpers/factories'
import { paddleBench } from '../../../tests/helpers/paddleCheck'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import TourDetailDialog from './TourDetailDialog'
import { useTripStore } from '../../store/tripStore'
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
@@ -96,16 +98,19 @@ describe('TourDetailDialog', () => {
expect(screen.getByText('Maximum altitude')).toBeInTheDocument()
expect(screen.getByText('Ascent')).toBeInTheDocument()
expect(screen.getByText('Descent')).toBeInTheDocument()
expect(screen.getByText('50 m')).toBeInTheDocument()
expect(screen.getByText('30 m')).toBeInTheDocument()
// The head's pills and the fields below both measure the line as it is now.
expect(screen.getAllByText('50 m').length).toBeGreaterThan(0)
expect(screen.getAllByText('30 m').length).toBeGreaterThan(0)
expect(screen.queryByText('999 m')).not.toBeInTheDocument()
expect(screen.queryByText('999 km')).not.toBeInTheDocument()
expect(screen.getByText('Bring water')).toBeInTheDocument()
expect(screen.queryByText('Opening Hours')).not.toBeInTheDocument()
expect(screen.queryByText('Participants')).not.toBeInTheDocument()
})
it('RS-01: shows Tour description and classified HTTPS information link without fetching it', () => {
it('RS-01: shows Tour description and opens the classified HTTPS information link without fetching it', () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
render(
<TourDetailDialog
{...defaultProps}
@@ -115,19 +120,36 @@ describe('TourDetailDialog', () => {
)
expect(screen.getByText('Ridge above the lake')).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Komoot · komoot.com' })
expect(link).toHaveAttribute('href', 'https://www.komoot.com/tour/42')
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow')
// The footer names the provider; the domain rides along in the tooltip.
const website = screen.getByRole('button', { name: 'Website: Komoot' })
expect(website).toHaveTextContent('Komoot')
fireEvent.click(website)
expect(openSpy).toHaveBeenCalledWith('https://www.komoot.com/tour/42', '_blank', 'noopener,noreferrer')
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
openSpy.mockRestore()
})
it('RS-02: labels calculated walking time and planned total as separate values', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, duration: 60, break_additional_minutes: 35, planned_duration_minutes: 125 }} />)
expect(screen.getByText('Walking time: 1 h')).toBeInTheDocument()
expect(screen.getByText('Breaks / additional time: 35 min')).toBeInTheDocument()
expect(screen.getByText('Planned total duration: 2 h 5 min · Manual override')).toBeInTheDocument()
// One time in the head, its parts named in full behind it; the fields below keep them apart.
expect(screen.getByLabelText('Walk: 1 h, Breaks: 35 min, Planned (Manual override): 2 h 5 min')).toHaveTextContent(
'2 h 5 min'
)
expect(screen.getByText('Walk')).toBeInTheDocument()
expect(screen.getByText('Breaks')).toBeInTheDocument()
expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument()
})
it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', has_waypoints: true, duration: 50 }} />)
const row = screen.getByText('Distance').closest('dl')
expect(row).toHaveClass('grid-cols-3')
expect(row).toContainElement(screen.getByText('Paddling'))
expect(row).toContainElement(screen.getByText('Planned'))
expect(screen.queryByText('Ascent')).not.toBeInTheDocument()
})
it('RS-01: rejects an unsafe legacy Tour website for rendering', () => {
@@ -144,7 +166,7 @@ describe('TourDetailDialog', () => {
it('uses existing assignment callbacks for add and remove actions', () => {
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42)
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42, 7)
rerender(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} assignments={{ '7': [{ id: 9, place }] as never }} />)
fireEvent.click(screen.getByRole('button', { name: /Remove from Day/i }))
@@ -176,10 +198,46 @@ describe('TourDetailDialog', () => {
expect(defaultProps.onUpdatePlace).toHaveBeenCalledWith(42, { name: 'New route name' })
})
it('labels GPX imports as view-only planner content', () => {
it('RC-03: names a GPX import as such, without a read-only notice in the plan', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, has_waypoints: false }} />)
expect(screen.getByText('GPX import')).toBeInTheDocument()
expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument()
expect(screen.queryByText(/view-only/)).not.toBeInTheDocument()
})
it('PR1-05B: names the import a tour was planned from while it is still in the trip', () => {
seedStore(useTripStore, { places: [buildPlace({ id: 7, name: 'Recorded ridge' }), place] })
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
expect(screen.getByText('Based on Recorded ridge')).toBeInTheDocument()
})
it('PR1-05B: says nothing of an origin that is gone from the trip', () => {
seedStore(useTripStore, { places: [place] })
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
expect(screen.queryByText(/Based on/)).not.toBeInTheDocument()
})
it('shows the grade on the tile and no second difficulty scale', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, difficulty: 'easy', max_hiking_difficulty: 1 }} />)
expect(screen.getAllByText('T1').length).toBeGreaterThan(0)
expect(screen.queryByText(/easy/i)).not.toBeInTheDocument()
})
it('PCT-001: a paddle tour shows its checklist and the safety row before the wind, a hike neither', () => {
const { unmount } = render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', paddle_check: paddleBench() }} />)
expect(screen.getByText('Checklist')).toBeInTheDocument()
expect(screen.getByTestId('paddle-check-summary')).toBeInTheDocument()
const safety = screen.getByRole('button', { name: /Paddling has risks/ })
expect(safety.compareDocumentPosition(screen.getByTestId('paddle-wind-card'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
unmount()
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, paddle_check: paddleBench() }} />)
expect(screen.queryByText('Checklist')).not.toBeInTheDocument()
expect(screen.queryByTestId('paddle-check-summary')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Paddling has risks/ })).not.toBeInTheDocument()
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import type { Day } from '../../types';
import { dayMenuLabel, tourDaysOf } from './tourDays';
import TourGpxDropZone from './TourGpxDropZone';
import type { TourGpxImport } from './useTourGpxImport';
function gpxImport(overrides: Partial<TourGpxImport> = {}): TourGpxImport {
return {
importing: false,
importFiles: vi.fn().mockResolvedValue(undefined),
pick: vi.fn(),
preview: null,
confirm: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn(),
...overrides,
};
}
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' });
const filesDrag = { dataTransfer: { types: ['Files'], files: [file], dropEffect: 'none' } };
const rowDrag = { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } };
describe('TourGpxDropZone', () => {
it('RC-04: a file dragged over the workspace says it will be imported, and a drop imports it', () => {
const importer = gpxImport();
render(
<TourGpxDropZone gpxImport={importer} canEdit>
<p>workspace</p>
</TourGpxDropZone>
);
const zone = screen.getByText('workspace').parentElement as HTMLElement;
fireEvent.dragEnter(zone, filesDrag);
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Drop GPX files to import them');
fireEvent.drop(zone, filesDrag);
expect(importer.importFiles).toHaveBeenCalledWith([file]);
expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument();
});
it('RC-04: a waypoint or tour row being dragged passes straight through', () => {
const importer = gpxImport();
render(
<TourGpxDropZone gpxImport={importer} canEdit>
<p>workspace</p>
</TourGpxDropZone>
);
const zone = screen.getByText('workspace').parentElement as HTMLElement;
fireEvent.dragEnter(zone, rowDrag);
fireEvent.drop(zone, rowDrag);
expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument();
expect(importer.importFiles).not.toHaveBeenCalled();
});
it('RC-04: leaving every child it entered takes the overlay away again', () => {
render(
<TourGpxDropZone gpxImport={gpxImport()} canEdit>
<p>workspace</p>
</TourGpxDropZone>
);
const zone = screen.getByText('workspace').parentElement as HTMLElement;
fireEvent.dragEnter(zone, filesDrag);
fireEvent.dragEnter(zone, filesDrag);
fireEvent.dragLeave(zone, filesDrag);
expect(screen.getByTestId('tour-gpx-drop')).toBeInTheDocument();
fireEvent.dragLeave(zone, filesDrag);
expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument();
});
it('RC-04: without the right to add tours it says so and imports nothing', () => {
const importer = gpxImport();
render(
<TourGpxDropZone gpxImport={importer} canEdit={false}>
<p>workspace</p>
</TourGpxDropZone>
);
const zone = screen.getByText('workspace').parentElement as HTMLElement;
fireEvent.dragEnter(zone, filesDrag);
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('You cannot add tours to this trip.');
fireEvent.drop(zone, filesDrag);
expect(importer.importFiles).not.toHaveBeenCalled();
});
it('RC-04: stays up and says so while an import runs', () => {
render(
<TourGpxDropZone gpxImport={gpxImport({ importing: true })} canEdit>
<p>workspace</p>
</TourGpxDropZone>
);
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Importing GPX…');
});
});
describe('tourDays', () => {
const days = [
{ id: 1, title: null, date: '2026-07-08' },
{ id: 2, title: 'Lakes', date: null },
{ id: 3, title: null, date: null },
] as Day[];
it('RC-02: lists the days a tour is on in the order of the trip, each once', () => {
const assignments = {
'3': [{ id: 9, place_id: 42 }],
'1': [
{ id: 7, place_id: 42 },
{ id: 8, place_id: 42 },
],
'2': [{ id: 6, place_id: 5 }],
} as never;
expect(tourDaysOf(42, days, assignments).map(({ day, index }) => [day.id, index])).toEqual([
[1, 0],
[3, 2],
]);
expect(tourDaysOf(99, days, assignments)).toEqual([]);
});
it('RC-02: names a day by its number, its date and its title as far as it has them', () => {
const t = (key: string, params?: Record<string, unknown>) => (key === 'dayplan.dayN' ? `Day ${params?.n}` : key);
expect(dayMenuLabel(days[1], 1, t, 'en')).toBe('Day 2: Lakes');
expect(dayMenuLabel(days[2], 2, t, 'en')).toBe('Day 3');
expect(dayMenuLabel(days[0], 0, t, 'en')).toMatch(/^Day 1, .*8/);
});
});
@@ -0,0 +1,101 @@
import { Ban, FileDown, Loader2 } from 'lucide-react';
import { useRef, useState, type DragEvent, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
import type { TourGpxImport } from './useTourGpxImport';
/** Only a drag of files from the desktop; a waypoint or a tour row being dragged is not one. */
const carriesFiles = (event: DragEvent) => Array.from(event.dataTransfer.types).includes('Files');
/**
* The tours workspace as a drop target for GPX files: a file dragged over it
* dims the workspace and says what will happen, and a drop hands the files to
* the same import the button runs. While an import runs, the overlay stays and
* says so. A drag of anything else (a waypoint in the list, the map) passes
* straight through.
*/
export default function TourGpxDropZone({
gpxImport,
canEdit,
className = 'relative',
children,
}: {
gpxImport: TourGpxImport;
canEdit: boolean;
/** Positions the zone; it must be positioned for the overlay to cover it. */
className?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
const [over, setOver] = useState(false);
// dragenter and dragleave fire for every child the pointer crosses, so the
// overlay goes only when the count of entered elements is back at zero.
const depth = useRef(0);
const reset = () => {
depth.current = 0;
setOver(false);
};
return (
<div
className={className}
onDragEnter={(event) => {
if (!carriesFiles(event)) return;
event.preventDefault();
depth.current += 1;
setOver(true);
}}
onDragOver={(event) => {
if (!carriesFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = canEdit && !gpxImport.importing ? 'copy' : 'none';
}}
onDragLeave={(event) => {
if (!carriesFiles(event)) return;
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setOver(false);
}}
onDrop={(event) => {
if (!carriesFiles(event)) return;
event.preventDefault();
reset();
if (canEdit) void gpxImport.importFiles(Array.from(event.dataTransfer.files));
}}
>
{children}
<TourGpxPreviewDialog gpxImport={gpxImport} />
{(over || gpxImport.importing) && (
<div
data-testid="tour-gpx-drop"
role="status"
className="pointer-events-none absolute inset-2.5 z-30 flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-accent backdrop-blur-[2px]"
style={{ background: 'color-mix(in srgb, var(--bg-card) 82%, transparent)' }}
>
<span className="grid h-12 w-12 place-items-center rounded-[14px] bg-surface-card text-content shadow-sm">
{gpxImport.importing ? (
<Loader2 size={22} strokeWidth={1.9} className="animate-spin" />
) : canEdit ? (
<FileDown size={22} strokeWidth={1.9} />
) : (
<Ban size={22} strokeWidth={1.9} />
)}
</span>
<span className="font-semibold text-content" style={fs(14, 'body')}>
{gpxImport.importing
? t('tours.import.importing')
: canEdit
? t('tours.import.drop')
: t('tours.import.dropNoPermission')}
</span>
{!gpxImport.importing && canEdit && (
<span className="max-w-[320px] text-center text-content-muted" style={fs(12, 'body')}>
{t('tours.import.dropHint')}
</span>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,216 @@
import type { PaddleBoatType, PaddleWaterContext, PlannableTourType, TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, FileDown, Info, Mountain, Waves } from 'lucide-react';
import { useId, useMemo, useState } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { MapViewAuto } from '../Map/MapViewAuto';
import { SoftPill } from '../Planner/planParts';
import { LABEL, Segmented } from '../shared/dialogParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TourNotice } from './tourParts';
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
const BADGE =
'inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 font-geist tabular-nums leading-[20px] text-content-secondary';
/** One track of the file as a row: its name, and what it measures as badges; a track the trip holds says so. */
function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile['tracks'][number] }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const heights = track.elevation_gain_m != null && track.elevation_loss_m != null;
return (
<li
data-testid="gpx-preview-track"
data-duplicate={track.duplicate || undefined}
className={`flex min-w-0 flex-col gap-1.5 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 ${track.duplicate ? 'opacity-60' : ''}`}
>
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
{track.name || name}
</span>
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
</div>
<div className="flex flex-wrap items-center gap-1.5" style={fs(11)}>
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
{heights ? (
<>
<span className={BADGE}>↑ {formatElevation(track.elevation_gain_m ?? 0, unit)}</span>
<span className={BADGE}>↓ {formatElevation(track.elevation_loss_m ?? 0, unit)}</span>
</>
) : (
<span className={BADGE}>{t('tours.import.previewNoHeights')}</span>
)}
<span className={BADGE}>{t('tours.import.previewPoints', { count: track.point_count })}</span>
</div>
</li>
);
}
/**
* PAD-02: a GPX file shown before anything is stored. The whole course on a
* map, every track with its measures, the tracks the trip already holds named
* as skipped, and the type to import them as; a paddle import names its boat
* and water, as a planned paddle tour does. Closing it stores nothing.
*/
export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpxImport }) {
const { preview } = gpxImport;
if (!preview) return null;
return <PreviewDialog gpxImport={gpxImport} files={preview} />;
}
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
const { t } = useTranslation();
const labelId = useId();
const tileUrl = useTileUrl(OFM_POSITRON);
const [type, setType] = useState<PlannableTourType>('hike');
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
const [water, setWater] = useState<PaddleWaterContext | null>(null);
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
const lines = useMemo(
() =>
files.flatMap((file) =>
file.tracks.map((track) => track.geometry.map(([lat, lng]): [number, number] => [lat, lng]))
),
[files]
);
const focusPoints = useMemo(() => lines.flat(), [lines]);
const paddleIncomplete = type === 'kayak' && (boat === null || water === null);
const canImport = fresh > 0 && !paddleIncomplete && !gpxImport.importing;
const fields: TourGpxImportFields =
type === 'kayak' && boat && water
? { tour_type: 'kayak', boat_type: boat, water_context: water }
: { tour_type: 'hike' };
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
return (
<DialogShell
onClose={gpxImport.cancel}
labelledBy={labelId}
width="editor"
blocked={gpxImport.importing}
header={
<DialogHeader
tile={
<DialogTile>
<FileDown size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={gpxImport.cancel}
eyebrow={t('tours.import.previewTitle')}
title={title}
sub={t('tours.import.previewHint')}
subWraps
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={gpxImport.cancel} disabled={gpxImport.importing}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="gpx-preview-import"
icon={<FileDown size={14} strokeWidth={2} />}
disabled={!canImport}
onClick={() => void gpxImport.confirm(fields)}
>
{t('tours.import.previewConfirm', { count: fresh })}
</DialogButton>
</DialogFooter>
}
>
<div
data-testid="gpx-preview-map"
className="relative h-[280px] flex-none overflow-hidden rounded-[14px] border border-edge-faint"
>
<MapViewAuto
places={[]}
route={lines}
tileUrl={tileUrl}
focusPoints={focusPoints}
focusKey={files.map(({ file }) => file.name).join('|')}
followSelection={false}
/>
</div>
<DialogSection label={t('tours.import.previewTracks', { count: tracks.length })}>
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
{tracks.map(({ file, track }, index) => (
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} track={track} />
))}
</ul>
{fresh === 0 && (
<div className="mt-2">
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.import.previewAllDuplicates')}
</TourNotice>
</div>
)}
</DialogSection>
<DialogSection label={t('tours.import.previewAs')}>
<div className="flex flex-col gap-3">
<Segmented<PlannableTourType>
value={type}
onChange={setType}
label={t('tours.import.previewAs')}
fill
options={[
{ value: 'hike', label: t('tourTypes.hike'), icon: <Mountain size={13} strokeWidth={2} /> },
{ value: 'kayak', label: t('tourTypes.kayak'), icon: <Waves size={13} strokeWidth={2} /> },
]}
/>
{type === 'kayak' && (
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.boat')}</span>
<Segmented<PaddleBoatType>
value={(boat ?? '') as PaddleBoatType}
onChange={setBoat}
label={t('tours.paddle.boat')}
fill
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
/>
</div>
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.water')}</span>
<Segmented<PaddleWaterContext>
value={(water ?? '') as PaddleWaterContext}
onChange={setWater}
label={t('tours.paddle.water')}
fill
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
/>
</div>
</div>
)}
{paddleIncomplete && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t('tours.paddle.chooseBoatImport')}
</TourNotice>
)}
</div>
</DialogSection>
</DialogShell>
);
}
@@ -0,0 +1,179 @@
import { Check, ChevronDown } from 'lucide-react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import { InfoDot } from './tourParts';
import { ORIGIN_LABEL_KEY, statusLabel } from './tourPresentation';
import type { TourListFilters } from './useTourListFilters';
function MenuRow({
active,
label,
count,
onClick,
}: {
active: boolean;
label: string;
count: number;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
style={fs(12.5, 'body')}
>
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
{count}
</span>
<span className="grid w-3.5 flex-none place-items-center">
{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}
</span>
</button>
);
}
function MenuSection({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div
role="group"
aria-label={label}
className="flex flex-col gap-px border-t border-edge-faint pt-1.5 first:border-t-0 first:pt-0"
>
<div className="flex items-center gap-1 px-2 pb-0.5">
<span className="font-semibold uppercase tracking-[.06em] text-content-faint" style={fs(9.5)}>
{label}
</span>
{hint && <InfoDot label={label} description={hint} />}
</div>
{children}
</div>
);
}
/**
* The filter of a tour list (PR1-13): one button that says what is set, and a
* menu with the day status and, where the list varies in them, the source,
* the stored routing limit and the tour type, each with how many tours it
* keeps. Choosing a set value again clears it.
*/
export default function TourListFilterMenu({ filters }: { filters: TourListFilters }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { facets, selection, statusCounts } = filters;
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
setOpen(false);
buttonRef.current?.focus();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey, true);
};
}, [open]);
return (
<div ref={rootRef} className="relative min-w-0">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="true"
aria-label={t('places.filterShow')}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
style={fs(12, 'body')}
>
<span className="min-w-0 truncate text-start">{statusLabel(filters.status, t)}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>
{filters.filtered.length}
</span>
{filters.activeFacets > 0 && (
<span
className="rounded-full bg-accent px-1.5 font-geist font-semibold leading-[16px] text-accent-text"
style={fs(10)}
>
+{filters.activeFacets}
</span>
)}
<ChevronDown
size={13}
strokeWidth={2.2}
className={`ms-auto flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div
role="group"
aria-label={t('places.filterShow')}
className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex max-h-[min(420px,60vh)] w-full min-w-[200px] flex-col gap-1.5 overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover"
>
<MenuSection label={t('tours.filter.status')}>
{(['all', 'unplanned', 'planned'] as const).map((status) => (
<MenuRow
key={status}
active={filters.status === status}
label={statusLabel(status, t)}
count={statusCounts[status]}
onClick={() => filters.setStatus(status)}
/>
))}
</MenuSection>
{facets.origin.length > 0 && (
<MenuSection label={t('tours.filter.source')}>
{facets.origin.map((option) => (
<MenuRow
key={option.value}
active={selection.origin === option.value}
label={t(ORIGIN_LABEL_KEY[option.value])}
count={option.count}
onClick={() => filters.select('origin', option.value)}
/>
))}
</MenuSection>
)}
{facets.grade.length > 0 && (
<MenuSection label={t('tours.filter.grade')} hint={t('tours.filter.gradeHint')}>
{facets.grade.map((option) => (
<MenuRow
key={option.value}
active={selection.grade === option.value}
label={`T${option.value}`}
count={option.count}
onClick={() => filters.select('grade', option.value)}
/>
))}
</MenuSection>
)}
{facets.type.length > 0 && (
<MenuSection label={t('tours.filter.type')}>
{facets.type.map((option) => (
<MenuRow
key={option.value}
active={selection.type === option.value}
label={t(`tourTypes.${option.value}`)}
count={option.count}
onClick={() => filters.select('type', option.value)}
/>
))}
</MenuSection>
)}
</div>
)}
</div>
);
}
+118 -30
View File
@@ -1,25 +1,126 @@
import '../../styles/fadeEnd.css'
import { useId, type ReactNode } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import { Link2 } from 'lucide-react'
import { fs } from '../shared/DialogShell'
import { SoftPill } from '../Planner/planParts'
import { TourFactPills, TourGradeTile } from './tourParts'
import { tourWebsitePresentation } from './tourPresentation'
import { TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts'
import type { Day } from '../../types'
import { endPlaceDrag, startPlaceDrag } from '../Map/markerDrag'
import { TourDayRowPills } from './TourDayRowFacts'
import { useTourDays } from './tourDays'
import { Tooltip } from '../shared/Tooltip'
const NO_DAYS: Day[] = []
interface TourListRowProps {
tour: TourListItem
/** The trip's days, to name the ones the tour is on; without them the row names none. */
days?: Day[]
selected?: boolean
disabled?: boolean
/** The row can be picked up and dropped on a day of the plan, like a place. */
draggableToDay?: boolean
onSelect: (tour: TourListItem, opener: HTMLElement) => void
action?: ReactNode
children?: ReactNode
/** The colour the tour's line has on the map, shown beside it so the two read as one. */
trackColor?: string
/** PR1-07: the person's switch for this tour's line on the trip map. */
mapToggle?: { hidden: boolean; onToggle: () => void; showLabel: string; hideLabel: string }
}
/** A tour in a list, shaped like a place row: tile, name, its facts as pills and the day action on the right. */
export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) {
/** The eye beside a row: shown on hover like the other actions, and always while the tour is hidden. */
function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['mapToggle']> }) {
const label = toggle.hidden ? toggle.showLabel : toggle.hideLabel
return (
<Tooltip label={label}>
<button
type="button"
aria-label={label}
aria-pressed={toggle.hidden}
onClick={event => { event.stopPropagation(); toggle.onToggle() }}
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${toggle.hidden ? 'bg-surface-tertiary' : 'bg-surface-card'}`}
>
{toggle.hidden ? <EyeOff size={13} strokeWidth={2.2} /> : <Eye size={13} strokeWidth={2.2} />}
</button>
</Tooltip>
)
}
/** The tile, the name, the description on a line of its own and one line of badges. */
function TourRowBody({ tour, nameId, tourDays, dimmed }: {
tour: TourListItem
nameId: string
tourDays: ReturnType<typeof useTourDays>
dimmed: boolean
}) {
return (
<div className={`flex min-w-0 flex-1 items-center gap-2.5 transition-opacity ${dimmed ? 'opacity-55' : ''}`}>
<TourGradeTile tour={tour} />
<div className="min-w-0 flex-1">
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content"
style={fs(13, 'body')}>{tour.name}</span>
{tour.description && (
<span
data-testid="tour-description-preview"
className="mt-0.5 block min-w-0 truncate text-content-faint"
style={fs(10.5)}
>
{tour.description}
</span>
)}
{/* One line of whole badges, most important first: one that does not fit wraps onto a
second line the fixed height hides, so a narrow sidebar shows two and never half of one. */}
<div
data-testid="tour-metrics"
className="-mx-0.5 mt-0.5 flex h-6 min-w-0 flex-wrap content-start gap-x-1.5 gap-y-2 overflow-hidden px-0.5 pt-0.5"
>
<TourCautionBadge tour={tour} />
<TourDayRowPills tour={tour} compact />
<TourDaysBadge tourDays={tourDays} />
</div>
</div>
</div>
)
}
/**
* The eye and the row's actions on the right. A hidden tour keeps its eye in
* sight; the rest come out on hover, focus or selection, as a plan row's
* controls do, so the name and badges have the room otherwise.
*/
function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps, 'action' | 'mapToggle'> & { selected: boolean }) {
const hoverToggle = mapToggle && !mapToggle.hidden ? mapToggle : null
return (
<>
{mapToggle?.hidden && <MapToggleButton toggle={mapToggle} />}
{(action || hoverToggle) && (
<div data-testid="tour-action-row" role="presentation"
onClick={event => event.stopPropagation()}
className={`flex-none items-center gap-1.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}>
{hoverToggle && <MapToggleButton toggle={hoverToggle} />}
{action}
</div>
)}
</>
)
}
/**
* A tour in a list, laid out like a stop in the day plan: its tile with the
* grade, the name, the description below it, and one line of the plan's
* white badges (time, distance, ascent, the days it is on, a caution). The day
* and delete actions sit on the right.
*/
export default function TourListRow({ tour,
days = NO_DAYS,
selected = false, disabled = false,
draggableToDay = false, onSelect, action, children, trackColor, mapToggle }: TourListRowProps) {
const nameId = useId()
const tourDays = useTourDays(tour.place_id, days)
const draggable = draggableToDay && !disabled
const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover'
const website = tourWebsitePresentation(tour.website)
const cursor = disabled ? 'cursor-default opacity-60' : draggable ? 'cursor-grab' : 'cursor-pointer'
return (
<li
role="option"
@@ -27,7 +128,11 @@ export default function TourListRow({ tour, selected = false, disabled = false,
aria-selected={selected}
aria-disabled={disabled || undefined}
aria-labelledby={nameId}
className={`group mb-px rounded-[12px] px-2.5 py-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] ${tone} ${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}`}
data-place-id={tour.place_id}
draggable={draggable || undefined}
onDragStart={draggable ? (event) => startPlaceDrag(event.dataTransfer, tour.place_id) : undefined}
onDragEnd={draggable ? endPlaceDrag : undefined}
className={`group mx-1.5 rounded-[10px] px-1.5 py-2 outline-none transition-colors focus-visible:bg-surface-hover ${tone} ${cursor}`}
onClick={event => { if (!disabled) onSelect(tour, event.currentTarget) }}
onKeyDown={event => {
if (disabled || event.target !== event.currentTarget) return
@@ -37,31 +142,14 @@ export default function TourListRow({ tour, selected = false, disabled = false,
}
}}
>
{/* The tile, the name and the day actions share the head line; the rest runs the
full width under them, below the tile too. */}
<div className="flex items-center gap-2.5">
<TourGradeTile tour={tour} />
<span id={nameId} className="block min-w-0 flex-1 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
{action && (
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5" onClick={event => event.stopPropagation()}>
{action}
</div>
{trackColor && (
// The line's colour on the map, as a plan row shows its category.
<span aria-hidden="true" className="-me-1 h-8 w-[3px] flex-none rounded-full" style={{ background: trackColor }} />
)}
<TourRowBody tour={tour} nameId={nameId} tourDays={tourDays} dimmed={!!mapToggle?.hidden} />
<TourRowActions action={action} mapToggle={mapToggle} selected={selected} />
</div>
{tour.description && (
<div data-testid="tour-description-preview" className="mt-2.5 rounded-[10px] bg-surface-card px-2 py-1.5 text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint" style={fs(11)}>
<span className="overflow-hidden text-ellipsis" style={{ lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{tour.description}
</span>
</div>
)}
<TourFactPills tour={tour} className={tour.description ? 'mt-1.5' : 'mt-2.5'}>
{website && (
<SoftPill icon={<Link2 size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span className="min-w-0 truncate">{website.provider ?? website.domain}</span>
</SoftPill>
)}
</TourFactPills>
{children}
</li>
)
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import TourWorkspaceRail, { type TourWorkspaceRailParts } from './TourWorkspaceRail';
import type { GpxConversion } from './conversion/useGpxConversion';
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
import type { TourPlannerController } from './planner/useTourPlanner';
import type { RecordedTrack } from './useRecordedTrack';
import type { WandererBrowser } from './wanderer/useWandererBrowser';
const seen = vi.hoisted(() => ({ rail: [] as Array<Record<string, unknown>> }));
vi.mock('./planner/TourPlannerPanels', () => ({
TourPlannerRail: (props: Record<string, unknown>) => {
seen.rail.push(props);
return <p>tour editor</p>;
},
}));
vi.mock('./wanderer/WandererBrowserRail', () => ({ default: () => <p>wanderer trails</p> }));
const planner = {} as TourPlannerController;
const conversion = {} as GpxConversion;
const recordedTrack = {} as RecordedTrack;
function wanderer(overrides: Partial<WandererBrowser>): WandererBrowser {
return { open: false, enabled: true, openBrowser: vi.fn(), ...overrides } as unknown as WandererBrowser;
}
function workspace(browser: WandererBrowser): TourWorkspaceRailParts {
return { tourPlanner: planner, wandererBrowser: browser, tourConversion: conversion, recordedTrack };
}
describe('TourWorkspaceRail', () => {
it('RS-04: shows the wanderer trails in place of the editor while they are browsed', () => {
render(<TourWorkspaceRail workspace={workspace(wanderer({ open: true }))} canEdit canAssign />);
expect(screen.getByText('wanderer trails')).toBeInTheDocument();
expect(screen.queryByText('tour editor')).not.toBeInTheDocument();
});
it('RS-04: offers the wanderer trails from the editor only while wanderer may be used', () => {
const browser = wanderer({});
seen.rail.length = 0;
const view = render(<TourWorkspaceRail workspace={workspace(browser)} canEdit canAssign={false} />);
expect(screen.getByText('tour editor')).toBeInTheDocument();
expect(seen.rail[seen.rail.length - 1]).toMatchObject({
planner,
conversion,
recordedTrack,
canEdit: true,
canAssign: false,
onOpenWanderer: browser.openBrowser,
});
view.rerender(<TourWorkspaceRail workspace={workspace(wanderer({ enabled: false }))} canEdit canAssign />);
expect(seen.rail[seen.rail.length - 1]?.onOpenWanderer).toBeUndefined();
});
it('PCW-001: hands the paddle plan and the checklist of the tour in view on to the editor rail', () => {
const trackPaddlePlan = { pick: null } as unknown as TrackPaddlePlan;
const paddleChecklist = { status: 'ready' } as unknown as PaddleChecklist;
const parts = { ...workspace(wanderer({})), trackPaddlePlan, paddleChecklist };
seen.rail.length = 0;
render(<TourWorkspaceRail workspace={parts} canEdit canAssign />);
expect(seen.rail[seen.rail.length - 1]?.trackPaddlePlan).toBe(trackPaddlePlan);
expect(seen.rail[seen.rail.length - 1]?.paddleChecklist).toBe(paddleChecklist);
});
});
@@ -0,0 +1,54 @@
import type { GpxConversion } from './conversion/useGpxConversion';
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
import { TourPlannerRail } from './planner/TourPlannerPanels';
import type { TourPlannerController } from './planner/useTourPlanner';
import type { RecordedTrack } from './useRecordedTrack';
import WandererBrowserRail from './wanderer/WandererBrowserRail';
import type { WandererBrowser } from './wanderer/useWandererBrowser';
/** What the tours workspace hands its left rail: the parts of the planner page's state, by their names there. */
export interface TourWorkspaceRailParts {
tourPlanner: TourPlannerController;
wandererBrowser: WandererBrowser;
tourConversion: GpxConversion;
recordedTrack: RecordedTrack;
/** The portages and locks of a recorded paddle track in view. */
trackPaddlePlan?: TrackPaddlePlan;
/** The obstacle checklist of the paddle tour open in the planner (PAD-06). */
paddleChecklist?: PaddleChecklist;
}
/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */
export default function TourWorkspaceRail({
workspace,
canEdit,
canAssign,
}: {
workspace: TourWorkspaceRailParts;
canEdit: boolean;
canAssign: boolean;
}) {
const {
tourPlanner,
wandererBrowser: wanderer,
tourConversion,
recordedTrack,
trackPaddlePlan,
paddleChecklist,
} = workspace;
return wanderer.open ? (
<WandererBrowserRail browser={wanderer} />
) : (
<TourPlannerRail
planner={tourPlanner}
canEdit={canEdit}
canAssign={canAssign}
conversion={tourConversion}
recordedTrack={recordedTrack}
trackPaddlePlan={trackPaddlePlan}
paddleChecklist={paddleChecklist}
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
/>
);
}
+207 -22
View File
@@ -3,12 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
import { useTripStore } from '../../store/tripStore'
import { toursApi } from '../../api/client'
import { tourGpxApi } from '../../api/tourGpx'
import ToursSidebar from './ToursSidebar'
import { pickFiles } from '../../../tests/helpers/filePicker'
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }))
vi.mock('../Map/MapViewAuto', () => ({ MapViewAuto: () => <div data-testid="gpx-preview-map-stub" /> }))
vi.mock('../shared/Toast', () => ({ useToast: () => toast }))
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
@@ -32,10 +36,25 @@ const tour: TourListItem = {
}
beforeEach(() => {
vi.restoreAllMocks()
Object.values(toast).forEach((notify) => notify.mockClear())
resetAllStores()
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
})
function previewTrack(overrides: Record<string, unknown> = {}) {
return {
name: 'Ridge',
geometry: [[47, 11, 600], [47.01, 11.01, 650], [47.02, 11.02, 640]] as [number, number, number][],
point_count: 3,
distance_km: 2.4,
elevation_gain_m: 50,
elevation_loss_m: 10,
duplicate: false,
...overrides,
}
}
describe('ToursSidebar', () => {
it('uses the compact counted dropdown for All, Unplanned and Planned', () => {
render(<ToursSidebar tripId={1} days={[]} tours={[tour, { ...tour, place_id: 43, name: 'Planned walk', planned: true }]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
@@ -56,15 +75,20 @@ describe('ToursSidebar', () => {
expect(row).toHaveClass('bg-surface-selected')
})
it('RS-01: previews the Tour description and classified information-link host', () => {
it('RS-01: puts the description on its own line between the name and the badges', () => {
const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 }
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
expect(row).toHaveTextContent('A quiet path above the lake')
expect(row).toHaveTextContent('Komoot')
expect(row).not.toHaveTextContent('komoot.com')
expect(row).toHaveTextContent('1 h 35 min')
expect(row.querySelector('[data-testid="tour-description-preview"] > span')).toHaveStyle({ WebkitLineClamp: '2' })
const line = within(row).getByTestId('tour-metrics')
expect(line).toHaveClass('flex-wrap', 'h-6', 'overflow-hidden')
expect(line).toHaveTextContent('1 h 35 min')
const description = within(row).getByTestId('tour-description-preview')
expect(line).not.toContainElement(description)
expect(description).toHaveTextContent('A quiet path above the lake')
expect(description).toHaveClass('truncate')
expect(description.compareDocumentPosition(line) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// The information link belongs to the detail, not the list row.
expect(row).not.toHaveTextContent('Komoot')
})
it('selects the tour from keyboard activation', () => {
@@ -98,15 +122,13 @@ describe('ToursSidebar', () => {
const metrics = row.querySelector('[data-testid="tour-metrics"]')!
expect(metrics).toHaveTextContent('1.9 km')
expect(metrics).toHaveTextContent('13 m')
expect(metrics).toHaveTextContent('11 m')
const labels = [...metrics.querySelectorAll('[aria-label]')].map(element => element.getAttribute('aria-label'))
expect(labels[0]).toContain('1.9 km')
expect(labels[1]).toContain('13 m')
expect(labels[2]).toContain('11 m')
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu')
expect(within(actionRow as HTMLElement).getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ })).toHaveAttribute('aria-haspopup', 'menu')
expect(container.querySelector('[data-testid="tour-metrics"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
@@ -200,12 +222,15 @@ describe('ToursSidebar', () => {
})
it('quarantines returned Tour ids before loading the imported Places', async () => {
const importCall = vi.spyOn(toursApi, 'importGpx').mockResolvedValue({
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({
tours: [tour], caution: false, skipped: 0,
})
const onToursChanged = vi.fn()
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
const { container } = render(<ToursSidebar
const refreshPlaces = vi.fn().mockResolvedValue(undefined)
seedStore(useTripStore, { refreshPlaces })
render(<ToursSidebar
tripId={1}
days={[]}
tours={[]}
@@ -215,22 +240,26 @@ describe('ToursSidebar', () => {
onSelectTour={vi.fn()}
/>)
const input = container.querySelector('input[type="file"]')!
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
fireEvent.change(input, { target: { files: [file] } })
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx')
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1))
// Only the places are read again, not the whole trip: that would rebuild the workspace.
await waitFor(() => expect(refreshPlaces).toHaveBeenCalledWith(1))
expect(loadTrip).not.toHaveBeenCalled()
expect(onToursChanged).toHaveBeenCalledWith([42])
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(loadTrip.mock.invocationCallOrder[0])
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(refreshPlaces.mock.invocationCallOrder[0])
expect(importCall).toHaveBeenCalledOnce()
expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' })
vi.restoreAllMocks()
})
it('reports duplicate-only GPX success without refetching or invalidating unchanged data', async () => {
vi.spyOn(toursApi, 'importGpx').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
const onToursChanged = vi.fn()
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
const { container } = render(<ToursSidebar
render(<ToursSidebar
tripId={1}
days={[]}
tours={[]}
@@ -240,13 +269,169 @@ describe('ToursSidebar', () => {
onSelectTour={vi.fn()}
/>)
fireEvent.change(container.querySelector('input[type=file]')!, {
target: { files: [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' })] },
})
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
])
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('All places were already in the trip.'))
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('Everything in that file is already in this trip.'))
expect(onToursChanged).not.toHaveBeenCalled()
expect(loadTrip).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('RC-04: names each file that fails and still imports the rest', async () => {
vi.spyOn(tourGpxApi, 'preview')
.mockRejectedValueOnce({ response: { data: { error: 'No track or route found in GPX file' } } })
.mockResolvedValueOnce({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
new File(['<gpx/>'], 'empty.gpx', { type: 'application/gpx+xml' }),
new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' }),
])
// The file without a track is named at once and left out of the preview.
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx'))
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported from GPX'))
expect(importCall).toHaveBeenCalledOnce()
expect(toast.info).toHaveBeenCalledWith('Imported with caution — elevation/metrics incomplete')
vi.restoreAllMocks()
})
it('PAD-02: shows the file before storing anything, and closing the preview stores nothing', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack(), previewTrack({ name: 'Back', duplicate: true })] })
const importCall = vi.spyOn(tourGpxApi, 'import')
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveTextContent('GPX preview')
expect(dialog).toHaveTextContent('ridge.gpx')
expect(screen.getAllByTestId('gpx-preview-track')).toHaveLength(2)
expect(screen.getAllByTestId('gpx-preview-track')[1]).toHaveTextContent('Already in the trip')
expect(screen.getAllByTestId('gpx-preview-track')[0]).toHaveTextContent('3 points')
// Only the track the trip does not hold yet is imported.
expect(screen.getByRole('button', { name: 'Import 1 tour' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
expect(importCall).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('PAD-02: a file the trip holds completely cannot be imported again, and says why', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack({ duplicate: true })] })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
expect(await screen.findByText('Every track is already in the trip, so there is nothing to import.')).toBeInTheDocument()
expect(screen.getByTestId('gpx-preview-import')).toBeDisabled()
vi.restoreAllMocks()
})
it('PAD-02: a paddle import waits for its boat and water, then sends them along', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack({ elevation_gain_m: null, elevation_loss_m: null })] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<gpx/>'], 'lake.gpx', { type: 'application/gpx+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
await screen.findByRole('dialog')
// A file without heights says so instead of showing zero.
expect(screen.getByTestId('gpx-preview-track')).toHaveTextContent('No heights in the file')
fireEvent.click(screen.getByRole('button', { name: 'Paddle tour' }))
const confirm = screen.getByTestId('gpx-preview-import')
expect(confirm).toBeDisabled()
expect(screen.getByText('Choose the boat and the water to import as a paddle tour.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'SUP' }))
fireEvent.click(screen.getByRole('button', { name: 'Lake' }))
expect(confirm).toBeEnabled()
fireEvent.click(confirm)
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' }))
vi.restoreAllMocks()
})
it('RC-04: refuses files that are not GPX before uploading anything', async () => {
const importCall = vi.spyOn(tourGpxApi, 'import')
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
new File(['x'], 'notes.txt', { type: 'text/plain' }),
])
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Only GPX files can be imported as tours.'))
expect(importCall).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('RC-02: names the days a tour is on in one badge, in the order of the trip', () => {
const days = [
{ id: 7, trip_id: 1, day_number: 1, title: null, date: null },
{ id: 8, trip_id: 1, day_number: 2, title: null, date: null },
{ id: 9, trip_id: 1, day_number: 3, title: 'Summit', date: null },
] as never[]
seedStore(useTripStore, {
assignments: { '9': [{ id: 2, place_id: 42 }], '7': [{ id: 1, place_id: 42 }] } as never,
})
render(<ToursSidebar tripId={1} days={days} tours={[tour]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const badge = within(screen.getByRole('option', { name: /Selected ridge walk/ })).getByTestId('tour-days-badge')
expect(badge).toHaveTextContent('Days 1, 3')
expect(badge).toHaveAttribute('aria-label', 'Day 1, Day 3: Summit')
})
it('PR1-13: offers the source and routing limit only where the list varies, and narrows by them', () => {
render(<ToursSidebar tripId={1} days={[]} tours={[
{ ...tour, place_id: 1, name: 'Drawn', origin: 'trek', max_hiking_difficulty: 2 },
{ ...tour, place_id: 2, name: 'Imported', origin: 'gpx', max_hiking_difficulty: null },
{ ...tour, place_id: 3, name: 'Snapshot', origin: 'wanderer', max_hiking_difficulty: 3 },
]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Show' }))
const limit = () => within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ })
fireEvent.click(limit())
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Snapshot')])
// Under that limit only the wanderer snapshot is left, so the source offers nothing else.
expect(within(screen.getByRole('group', { name: 'Source' })).queryByRole('button', { name: /GPX import/ })).toBeNull()
fireEvent.click(limit())
fireEvent.click(within(screen.getByRole('group', { name: 'Source' })).getByRole('button', { name: /GPX import\s*1/ }))
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Imported')])
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('+1')
// Only one tour type in the list: no type filter.
expect(screen.queryByRole('group', { name: 'Type' })).toBeNull()
// The import has no routing limit, so none is offered for it; the day status still narrows it away.
expect(screen.queryByRole('group', { name: 'Routing limit' })).toBeNull()
fireEvent.click(within(screen.getByRole('group', { name: 'Status' })).getByRole('button', { name: /^Planned/ }))
expect(screen.getByRole('status')).toHaveTextContent('No tour matches these filters.')
fireEvent.click(screen.getByRole('button', { name: 'Reset filters' }))
expect(screen.getAllByRole('option')).toHaveLength(3)
})
it('PR1-07/08: a row shows its line colour, and its eye hides the tour from the map until shown again', () => {
seedStore(useAuthStore, { user: { id: 3 } as never })
seedStore(useTripStore, { places: [{ id: 42, route_color: '#e11d48' }] as never })
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const row = screen.getByRole('option', { name: /Selected ridge walk/ })
expect(row.querySelector('[aria-hidden="true"][style]')).toHaveStyle({ background: '#e11d48' })
fireEvent.click(within(row).getByRole('button', { name: 'Hide on the map' }))
expect(useTourMapVisibilityStore.getState().hidden).toEqual([42])
expect(screen.getByRole('status')).toHaveTextContent('Hidden on the map')
// A hidden tour keeps its eye in sight, not only on hover.
expect(within(row).getByRole('button', { name: 'Show on the map' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Show all' }))
expect(useTourMapVisibilityStore.getState().hidden).toEqual([])
})
})
+67 -116
View File
@@ -1,20 +1,22 @@
import React, { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, FileDown } from 'lucide-react'
import React, { useEffect, useMemo, useRef } from 'react'
import { EyeOff, FileDown } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import type { Day } from '../../types'
import { tourRepo } from '../../repo/tourRepo'
import { useTripStore } from '../../store/tripStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import TourListRow from './TourListRow'
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import EmptyState from '../shared/EmptyState'
import { TourDayMenu } from './tourParts'
import { filterTours, type TourFilter } from './tourPresentation'
const MAX_FILE_BYTES = 10 * 1024 * 1024
import TourListFilterMenu from './TourListFilterMenu'
import { useTourListFilters } from './useTourListFilters'
import TourGpxPreviewDialog from './TourGpxPreviewDialog'
import { useTourGpxImport } from './useTourGpxImport'
import { type AssignTourToDay, useTourDayAssignment } from './useTourDayAssignment'
import { useTourMapVisibility } from './useTourMapVisibility'
import { useTripStore } from '../../store/tripStore'
import { resolveTrackColor } from '../Map/trackColors'
interface ToursSidebarProps extends TourPermissionProps {
tripId: number
@@ -22,7 +24,7 @@ interface ToursSidebarProps extends TourPermissionProps {
tours: TourListItem[]
loading?: boolean
selectedPlaceId?: number | null
onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise<void | boolean>
onAssignToDay: AssignTourToDay
pushUndo?: (label: string, undo: () => void | Promise<void>) => void
/** Imported place ids are excluded immediately; no ids means refresh derived Tour metadata. */
onToursChanged?: (placeIds?: number[]) => void | Promise<void>
@@ -43,82 +45,25 @@ interface ToursSidebarProps extends TourPermissionProps {
export default function ToursSidebar({ tripId, days, tours, loading = false, selectedPlaceId = null, onAssignToDay, onToursChanged, onSelectTour, canEdit: editPermission, canAssign: assignPermission }: ToursSidebarProps): React.ReactElement {
const { t } = useTranslation()
const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission })
const toast = useToast()
const loadTrip = useTripStore(s => s.loadTrip)
const [filter, setFilter] = useState<TourFilter>('all')
const [importing, setImporting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const filterRef = useRef<HTMLDivElement>(null)
const filterButtonRef = useRef<HTMLButtonElement>(null)
const [filterOpen, setFilterOpen] = useState(false)
const handleImportClick = () => { if (canEdit) fileInputRef.current?.click() }
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!canEdit || !file) return
const ext = file.name.toLowerCase().split('.').pop()
if (ext !== 'gpx') { toast.error(t('places.importFileUnsupported')); return }
if (file.size > MAX_FILE_BYTES) { toast.error(t('places.importFileTooLarge', { maxMb: 10 })); return }
setImporting(true)
try {
const result = await tourRepo.importGpx(tripId, file)
if (result.tours.length > 0) {
// The server publishes place:created with sender echo suppression.
// Reload the importing client's trip so new places and route geometry
// enter the shared store used by the day map.
await onToursChanged?.(result.tours.map(tour => tour.place_id))
await loadTrip(tripId)
toast.success(t('tours.import.success', { count: result.tours.length }))
if (result.caution) toast.info(t('tours.import.caution'))
} else {
toast.warning(t('places.importAllSkipped'))
}
} catch (err: any) {
const message = err?.response?.data?.error
toast.error(message === 'No track or route found in GPX file' ? t('tours.import.noTrack') : t('tours.import.error'))
} finally {
setImporting(false)
}
}
const handleAssign = async (tour: TourListItem, day: Day, index: number) => {
if (!canAssign) return
const assigned = await onAssignToDay(tour.place_id, day.id)
if (assigned === false) return
toast.success(t('tours.addedToDay', { n: index + 1 }))
}
const counts = {
all: tours.length,
unplanned: tours.filter(tr => !tr.planned).length,
planned: tours.filter(tr => tr.planned).length,
}
const filtered = filterTours(tours, filter)
const gpxImport = useTourGpxImport({ tripId, canEdit, onToursChanged })
const filters = useTourListFilters(tours)
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
const tourIds = useMemo(() => new Set(tours.map(tour => tour.place_id)), [tours])
const visibility = useTourMapVisibility(tripId, tourIds)
// The colour each line has on the map, so a row and its line read as one (PR1-08).
const places = useTripStore(state => state.places)
const trackColors = useMemo(
() => new Map(places.filter(place => tourIds.has(place.id)).map(place => [place.id, resolveTrackColor(place)])),
[places, tourIds],
)
const listRef = useRef<HTMLUListElement>(null)
// A tour picked on the map shows in the list too, scrolled into view.
useEffect(() => {
if (!filterOpen) return
const onDown = (event: MouseEvent) => {
if (!filterRef.current?.contains(event.target as Node)) setFilterOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.stopPropagation()
setFilterOpen(false)
filterButtonRef.current?.focus()
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [filterOpen])
const filterLabel = filter === 'all' ? t('places.all') : filter === 'unplanned' ? t('places.unplanned') : t('places.planned')
const filterCount = counts[filter]
if (selectedPlaceId == null) return
const row = listRef.current?.querySelector<HTMLElement>(`[data-place-id="${selectedPlaceId}"]`)
row?.scrollIntoView?.({ block: 'nearest' })
}, [selectedPlaceId])
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: 'var(--font-system)' }}>
@@ -126,61 +71,67 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
{/* GPX import stays — the tour-appropriate import path. List Import
(place/route-of-driving oriented) is deliberately absent here. */}
<div>
<Tooltip label={t('places.importFile')}>
<button type="button" onClick={handleImportClick} disabled={!canEdit || importing}
<Tooltip label={t('tours.import.hint')}>
<button type="button" onClick={gpxImport.pick} disabled={!canEdit || gpxImport.importing}
className="flex h-8 w-full min-w-0 items-center justify-center gap-1.5 overflow-hidden rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
style={fs(12.5, 'body')}>
<FileDown size={14} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{t('places.importFile')}</span>
<span className="truncate">{t('tours.import.button')}</span>
</button>
</Tooltip>
<input ref={fileInputRef} type="file" accept=".gpx" onChange={handleFileChange} style={{ display: 'none' }} />
</div>
<div ref={filterRef} className="relative min-w-0">
<button ref={filterButtonRef} type="button" onClick={() => setFilterOpen(value => !value)}
aria-expanded={filterOpen} aria-haspopup="listbox" aria-label={t('places.filterShow')}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
style={fs(12, 'body')}>
<span className="min-w-0 truncate text-start">{filterLabel}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{filterCount}</span>
<ChevronDown size={13} strokeWidth={2.2} className={`ms-auto flex-none text-content-faint transition-transform ${filterOpen ? 'rotate-180' : ''}`} />
</button>
{filterOpen && (
<div role="group" aria-label={t('places.filterShow')} className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex w-full min-w-[180px] flex-col gap-px rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
{(['all', 'unplanned', 'planned'] as const).map(id => {
const label = id === 'all' ? t('places.all') : id === 'unplanned' ? t('places.unplanned') : t('places.planned')
const active = filter === id
return <button key={id} type="button" onClick={() => { setFilter(id); setFilterOpen(false) }} aria-pressed={active}
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
style={fs(12.5, 'body')}>
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>{counts[id]}</span>
<span className="grid w-3.5 flex-none place-items-center">{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}</span>
</button>
})}
</div>
)}
</div>
<TourListFilterMenu filters={filters} />
{visibility.hiddenCount > 0 && (
<div role="status" className="flex min-w-0 items-center gap-2 rounded-[10px] bg-surface-card px-2.5 py-1.5 shadow-sm ring-1 ring-inset ring-edge-faint">
<EyeOff size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
<span className="min-w-0 flex-1 truncate text-content-secondary" style={fs(12, 'body')}>{t('tours.visibility.hidden')}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{visibility.hiddenCount}</span>
<button type="button" onClick={visibility.showAll}
className="flex-none rounded-full px-2 py-0.5 font-semibold text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(11.5, 'body')}>
{t('tours.visibility.showAll')}
</button>
</div>
)}
</div>
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
{!loading && filtered.length === 0 ? (
{!loading && filters.emptyByFilter ? (
<div role="status" className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint">
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('tours.filter.empty')}</p>
<button type="button" onClick={filters.reset}
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(11.5, 'body')}>
{t('tours.filter.reset')}
</button>
</div>
) : !loading && filters.filtered.length === 0 ? (
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
) : (
<ul role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
{filtered.map(tour => (
<ul ref={listRef} role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
{filters.filtered.map(tour => (
<TourListRow
key={tour.place_id}
tour={tour}
days={days}
draggableToDay={canAssign && days.length > 0}
selected={tour.place_id === selectedPlaceId}
trackColor={trackColors.get(tour.place_id)}
mapToggle={{
hidden: visibility.isHidden(tour.place_id),
onToggle: () => visibility.toggle(tour.place_id),
showLabel: t('tours.visibility.show'),
hideLabel: t('tours.visibility.hide'),
}}
onSelect={(selectedTour, opener) => onSelectTour?.(selectedTour, opener)}
action={<TourDayMenu days={days} placeId={tour.place_id} label={t('tours.addToDay')} disabled={!canAssign} onPick={(day, index) => { void handleAssign(tour, day, index) }} />}
action={<TourDayMenu days={days} placeId={tour.place_id} label={t('tours.addToDay')} assignment={dayAssignment} />}
/>
))}
</ul>
)}
</div>
<TourGpxPreviewDialog gpxImport={gpxImport} />
</div>
)
}
@@ -0,0 +1,274 @@
import { act, renderHook } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import TourConversionSection from './TourConversionSection';
import { useGpxConversion } from './useGpxConversion';
const routing = vi.hoisted(() => ({ route: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../planner/tourRouting', () => ({ routeWalkingTour: routing.route }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const imported: TourListItem = {
place_id: 14,
name: 'Recorded ridge',
description: 'Above the lake',
website: null,
tour_type: 'hike',
distance: 2,
elevation_gain: 100,
elevation_loss: 100,
duration: null,
difficulty: 'moderate',
wanderer_ref: 'https://wanderer.test#abc',
match_confidence: 1,
max_hiking_difficulty: 2,
planned: false,
caution: false,
has_waypoints: false,
};
/** A straight track north, about two kilometres. */
const track: [number, number][] = Array.from({ length: 181 }, (_, i) => [47 + (i * 2) / 180 / 111.2, 11]);
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
return {
canEdit: true,
mode: { type: 'view-gpx', placeId: imported.place_id, tour: imported },
readOnlyGpxTour: { tour: imported, routeGeometry: null },
readOnlyGpxAnalysis: { routeCoordinates: track },
startDerivedTour: vi.fn().mockReturnValue(true),
...overrides,
} as unknown as TourPlannerController;
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
routing.route.mockResolvedValue({ coordinates: track, distanceMeters: 2000, durationSeconds: 1800 });
});
describe('useGpxConversion', () => {
it('PR1-05A: analyses only when asked, then compares the routed suggestion to the track', async () => {
const { result } = renderHook(() => useGpxConversion(planner()));
expect(result.current.status).toBe('idle');
expect(result.current.available).toBe(true);
expect(routing.route).not.toHaveBeenCalled();
await act(async () => {
await result.current.analyze();
});
expect(routing.route).toHaveBeenCalledWith(result.current.points, expect.any(AbortSignal), 2);
expect(result.current.status).toBe('ready');
expect(result.current.comparison?.withinCorridor).toBe(1);
expect(result.current.line).toEqual(track);
});
it('PR1-05A: routes an import without a grade with T3 paths allowed', async () => {
const ungraded = { ...imported, max_hiking_difficulty: null };
const { result } = renderHook(() =>
useGpxConversion(
planner({
mode: { type: 'view-gpx', placeId: ungraded.place_id, tour: ungraded },
readOnlyGpxTour: { tour: ungraded, routeGeometry: null },
} as Partial<TourPlannerController>)
)
);
await act(async () => {
await result.current.analyze();
});
expect(routing.route).toHaveBeenLastCalledWith(expect.any(Array), expect.any(AbortSignal), 3);
});
it('PR1-05A: says so when no route is found, and stays retryable', async () => {
routing.route.mockResolvedValueOnce(null);
const { result } = renderHook(() => useGpxConversion(planner()));
await act(async () => {
await result.current.analyze();
});
expect(result.current.status).toBe('failed');
expect(result.current.error).toBe('routing');
await act(async () => {
await result.current.analyze();
});
expect(result.current.status).toBe('ready');
});
it('PR1-05A: a track too short to plan from fails without asking the router', async () => {
const { result } = renderHook(() =>
useGpxConversion(
planner({
readOnlyGpxAnalysis: {
routeCoordinates: [
[47, 11],
[47, 11],
],
},
} as Partial<TourPlannerController>)
)
);
await act(async () => {
await result.current.analyze();
});
// Two identical points simplify to start and end, which the router may still connect;
// a single point has nothing to plan from.
const single = renderHook(() =>
useGpxConversion(
planner({ readOnlyGpxAnalysis: { routeCoordinates: [[47, 11]] } } as Partial<TourPlannerController>)
)
);
expect(single.result.current.available).toBe(false);
});
it('PR1-05B: replacing hands the points to the planner as the import planned over in place', async () => {
const controller = planner();
const { result } = renderHook(() => useGpxConversion(controller));
await act(async () => {
await result.current.analyze();
});
let started = false;
act(() => {
started = result.current.adopt('replace');
});
expect(started).toBe(true);
expect(controller.startDerivedTour).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Recorded ridge',
description: 'Above the lake',
difficulty: 2,
sourcePlaceId: 14,
inPlace: true,
})
);
expect(result.current.status).toBe('idle');
});
it('PR1-05B: a new tour beside the import remembers where it came from', async () => {
const controller = planner();
const { result } = renderHook(() => useGpxConversion(controller));
await act(async () => {
await result.current.analyze();
});
act(() => {
result.current.adopt('new');
});
expect(controller.startDerivedTour).toHaveBeenCalledWith(
expect.objectContaining({ sourcePlaceId: 14, inPlace: false })
);
});
it('PR1-05B: nothing is adopted before the suggestion is ready', () => {
const controller = planner();
const { result } = renderHook(() => useGpxConversion(controller));
expect(result.current.adopt('new')).toBe(false);
expect(controller.startDerivedTour).not.toHaveBeenCalled();
});
it('PR1-05A: another import in view starts idle again, and discarding forgets the analysis', async () => {
const { result, rerender } = renderHook(({ controller }) => useGpxConversion(controller), {
initialProps: { controller: planner() },
});
await act(async () => {
await result.current.analyze();
});
expect(result.current.status).toBe('ready');
const other = { ...imported, place_id: 15 };
rerender({
controller: planner({
mode: { type: 'view-gpx', placeId: 15, tour: other },
readOnlyGpxTour: { tour: other, routeGeometry: null },
} as Partial<TourPlannerController>),
});
expect(result.current.status).toBe('idle');
rerender({ controller: planner() });
await act(async () => {
await result.current.analyze();
});
act(() => result.current.cancel());
expect(result.current.status).toBe('idle');
});
it('PR1-05A: is not offered without the right to add tours', () => {
const { result } = renderHook(() => useGpxConversion(planner({ canEdit: false })));
expect(result.current.available).toBe(false);
});
});
describe('TourConversionSection', () => {
function Section({ controller }: { controller: TourPlannerController }) {
const conversion = useGpxConversion(controller);
return <TourConversionSection conversion={conversion} />;
}
it('PR1-05A: offers the analysis, then the comparison with both ways to take it over', async () => {
const controller = planner();
render(<Section controller={controller} />);
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
expect(await screen.findByRole('button', { name: 'Replace the import' })).toBeInTheDocument();
expect(screen.getByText('Within 50 m of the track')).toBeInTheDocument();
expect(screen.getByText('100 %')).toBeInTheDocument();
expect(
screen.getByText('The suggestion follows the track closely. It is routed anew, so it is not the same line.')
).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Replace the import' }));
expect(controller.startDerivedTour).toHaveBeenCalledWith(expect.objectContaining({ inPlace: true }));
expect(toast.info).toHaveBeenCalledWith(
'Opened in the editor. Save it to replace the import; the recorded track stays restorable.'
);
});
it('PR1-05B: plans a new tour beside the import on request', async () => {
const controller = planner();
render(<Section controller={controller} />);
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
await userEvent.click(await screen.findByRole('button', { name: 'Plan as new tour' }));
expect(controller.startDerivedTour).toHaveBeenCalledWith(expect.objectContaining({ inPlace: false }));
expect(toast.info).toHaveBeenCalledWith('Opened as a new tour. Save it to keep it; the import stays as it is.');
});
it('PR1-05A: warns when the suggestion leaves the track for longer stretches', async () => {
const elsewhere: [number, number][] = track.map(([lat, lng]) => [lat, lng + 0.01]);
routing.route.mockResolvedValue({ coordinates: elsewhere, distanceMeters: 2000, durationSeconds: 1800 });
render(<Section controller={planner()} />);
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
expect(
await screen.findByText(
'The suggestion leaves the track over longer stretches. Check it on the map before you plan with it.'
)
).toBeInTheDocument();
});
it('PR1-05A: reports a failed routing and retries it', async () => {
routing.route.mockResolvedValueOnce(null);
render(<Section controller={planner()} />);
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
expect(await screen.findByText('No route was found through the control points.')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('button', { name: 'Replace the import' })).toBeInTheDocument();
});
it('PR1-05A: discarding returns to the offer', async () => {
render(<Section controller={planner()} />);
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
await userEvent.click(await screen.findByRole('button', { name: 'Discard' }));
expect(screen.getByRole('button', { name: 'Plan an editable version' })).toBeInTheDocument();
});
it('PR1-05A: shows nothing to someone who may not add tours', () => {
const { container } = render(<Section controller={planner({ canEdit: false })} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,176 @@
import { AlertTriangle, CheckCircle2, CopyPlus, Loader2, Replace, Route, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { DialogButton, fs } from '../../shared/DialogShell';
import { useToast } from '../../shared/Toast';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, RailCard, RailFacts, type TourMetricField } from '../tourParts';
import { CORRIDOR_M, FAITHFUL_SHARE } from './gpxConversion';
import type { GpxConversion } from './useGpxConversion';
/** The two lines of the comparison as the map draws them: the import dotted, the suggestion solid. */
function LineLegend() {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-content-muted" style={fs(11, 'body')}>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="w-5 border-t-[3px] border-dotted border-info" />
{t('tours.conversion.legendImport')}
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="h-[3px] w-5 rounded-full bg-info" />
{t('tours.conversion.legendSuggestion')}
</span>
</div>
);
}
/** One line of status inside the card: an icon and a sentence. */
function StatusLine({
icon,
tone = 'muted',
children,
}: {
icon: ReactNode;
tone?: 'muted' | 'warning';
children: ReactNode;
}) {
return (
<p
role="status"
className={`m-0 flex items-start gap-2 leading-snug ${tone === 'warning' ? 'text-warning' : 'text-content-secondary'}`}
style={fs(12, 'body')}
>
<span className="mt-[2px] flex-none">{icon}</span>
<span className="min-w-0">{children}</span>
</p>
);
}
/**
* In the view of an import: plan an editable tour from it. Asked for, the
* import's track turns into control points routed anew, and the card compares
* that route to the track before anything is taken over. Taking it over either
* plans the import over in place (its days and notes stay, the recorded track
* is kept to go back to) or opens a new tour beside it. Both open in the editor
* and are stored only when saved.
*/
export default function TourConversionSection({ conversion }: { conversion: GpxConversion }) {
const { t } = useTranslation();
const toast = useToast();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
if (!conversion.available) return null;
const adopt = (how: 'replace' | 'new') => {
if (conversion.adopt(how))
toast.info(t(how === 'replace' ? 'tours.conversion.replacing' : 'tours.conversion.adopted'));
};
const card = (children: ReactNode) => (
<RailCard icon={<Route size={14} strokeWidth={2} />} title={t('tours.conversion.title')}>
{children}
</RailCard>
);
if (conversion.status === 'idle') {
return card(
<div className="flex flex-col items-start gap-2.5">
<p className="m-0 leading-snug text-content-secondary" style={fs(12, 'body')}>
{t(conversion.paddle ? 'tours.conversion.introPaddle' : 'tours.conversion.intro')}
</p>
<CardAction onClick={() => void conversion.analyze()} icon={<Route size={12} strokeWidth={2.2} />}>
{t('tours.conversion.analyze')}
</CardAction>
</div>
);
}
if (conversion.status === 'analyzing') {
return card(
<StatusLine icon={<Loader2 size={13} strokeWidth={2} className="animate-spin text-content-faint" />}>
{t('tours.conversion.analyzing', { count: conversion.points.length })}
</StatusLine>
);
}
if (conversion.status === 'failed' || !conversion.comparison) {
return card(
<div className="flex flex-col items-start gap-2">
<StatusLine tone="warning" icon={<AlertTriangle size={13} strokeWidth={2.2} />}>
{t(conversion.error === 'track' ? 'tours.conversion.failedTrack' : 'tours.conversion.failedRouting')}
</StatusLine>
<button
type="button"
onClick={() => void conversion.analyze()}
className="font-semibold text-content underline"
style={fs(12, 'body')}
>
{t('tours.planner.retry')}
</button>
</div>
);
}
const { comparison } = conversion;
const faithful = comparison.withinCorridor >= FAITHFUL_SHARE;
const facts: TourMetricField[] = [
{ label: t('tours.conversion.importLength'), value: formatDistance(comparison.originalKm, unit) },
{ label: t('tours.conversion.suggestionLength'), value: formatDistance(comparison.suggestionKm, unit) },
{
label: t('tours.conversion.withinCorridor', { m: CORRIDOR_M }),
value: `${Math.round(comparison.withinCorridor * 100)} %`,
},
{ label: t('tours.conversion.maxDeviation'), value: `${Math.round(comparison.maxDeviationM)} m` },
{ label: t('tours.conversion.controlPoints'), value: String(conversion.points.length) },
];
return card(
<div className="flex flex-col gap-3">
<LineLegend />
<RailFacts facts={facts} />
<StatusLine
tone={faithful ? 'muted' : 'warning'}
icon={
faithful ? (
<CheckCircle2 size={13} strokeWidth={2.2} className="text-success" />
) : (
<AlertTriangle size={13} strokeWidth={2.2} />
)
}
>
{t(
faithful
? conversion.paddle
? 'tours.conversion.closePaddle'
: 'tours.conversion.close'
: 'tours.conversion.different'
)}
</StatusLine>
<div className="flex flex-col gap-2 border-t border-edge-faint pt-3">
<Tooltip label={t('tours.conversion.replaceHint')}>
<span className="flex [&>button]:flex-1">
<DialogButton
variant="primary"
onClick={() => adopt('replace')}
icon={<Replace size={14} strokeWidth={2} />}
>
{t('tours.conversion.replace')}
</DialogButton>
</span>
</Tooltip>
<div className="flex gap-2">
<Tooltip label={t('tours.conversion.adoptHint')}>
<span className="flex flex-1 [&>button]:flex-1">
<DialogButton onClick={() => adopt('new')} icon={<CopyPlus size={14} strokeWidth={2} />}>
{t('tours.conversion.adopt')}
</DialogButton>
</span>
</Tooltip>
<DialogButton onClick={conversion.cancel} icon={<X size={14} strokeWidth={2} />}>
{t('tours.conversion.discard')}
</DialogButton>
</div>
</div>
</div>
);
}
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { CORRIDOR_M, compareRoutes, deriveControlPoints, lineLengthMeters, type TrackPoint } from './gpxConversion';
/** About 111 m per 0.001 degree of latitude. */
const north = (lat: number, lng = 11): TrackPoint => ({ lat, lng });
/** A straight line north, one point every ~11 m, `km` long. */
function straightTrack(km: number): TrackPoint[] {
const steps = Math.round(km * 90);
return Array.from({ length: steps + 1 }, (_, i) => north(47 + (i * km) / steps / 111.2));
}
/** An L: north for `legKm`, then east for `legKm`. */
function bentTrack(legKm: number): TrackPoint[] {
const up = straightTrack(legKm);
const corner = up[up.length - 1];
const east = Array.from({ length: 90 }, (_, i) => ({
lat: corner.lat,
lng: corner.lng + ((i + 1) * legKm) / 90 / 75.8,
}));
return [...up, ...east];
}
describe('deriveControlPoints', () => {
it('PR1-05A: keeps start and end and nothing else on a short straight track', () => {
const track = straightTrack(1);
const points = deriveControlPoints(track);
expect(points).toEqual([track[0], track[track.length - 1]]);
});
it('PR1-05A: keeps the bend a track turns at', () => {
const track = bentTrack(1);
const points = deriveControlPoints(track);
expect(points.length).toBe(3);
const corner = track[90];
expect(points[1].lat).toBeCloseTo(corner.lat, 3);
expect(points[1].lng).toBeCloseTo(corner.lng, 3);
});
it('PR1-05A: adds a point wherever a track runs on longer than the longest gap', () => {
const track = straightTrack(10);
const points = deriveControlPoints(track, { maxGapM: 2_500 });
expect(points.length).toBeGreaterThanOrEqual(5);
for (let i = 1; i < points.length; i += 1) {
expect(lineLengthMeters([points[i - 1], points[i]])).toBeLessThanOrEqual(2_600);
}
});
it('PR1-05A: never keeps more points than asked for, start and end included', () => {
// A zigzag every point of which is a bend.
const zigzag = Array.from({ length: 400 }, (_, i) => ({ lat: 47 + i * 0.0009, lng: 11 + (i % 2) * 0.002 }));
const points = deriveControlPoints(zigzag, { maxPoints: 12 });
expect(points.length).toBeLessThanOrEqual(12);
expect(points[0]).toEqual(zigzag[0]);
expect(points[points.length - 1]).toEqual(zigzag[zigzag.length - 1]);
});
it('PR1-05A: merges points closer than the minimum spacing, keeping start and end', () => {
const track = [north(47), north(47.0002), north(47.0004), north(47.02)];
const points = deriveControlPoints(track, { minSpacingM: 150 });
expect(points[0]).toEqual(track[0]);
expect(points[points.length - 1]).toEqual(track[3]);
expect(points).not.toContainEqual(track[1]);
});
it('PR1-05A: has nothing to offer for a track of fewer than two points', () => {
expect(deriveControlPoints([])).toEqual([]);
expect(deriveControlPoints([north(47)])).toEqual([]);
});
});
describe('compareRoutes', () => {
it('PR1-05A: a suggestion on the track itself lies wholly in the corridor', () => {
const track = straightTrack(2);
const result = compareRoutes(track, [track[0], track[track.length - 1]]);
expect(result.withinCorridor).toBe(1);
expect(result.maxDeviationM).toBeLessThan(1);
expect(result.originalKm).toBeCloseTo(2, 1);
expect(result.suggestionKm).toBeCloseTo(2, 1);
});
it('PR1-05A: a suggestion that cuts a corner says how far it strays and for how much of the track', () => {
const track = bentTrack(1);
const shortcut = [track[0], track[track.length - 1]];
const result = compareRoutes(track, shortcut);
expect(result.maxDeviationM).toBeGreaterThan(500);
expect(result.withinCorridor).toBeLessThan(0.5);
expect(result.suggestionKm).toBeLessThan(result.originalKm);
});
it('PR1-05A: counts a stray of exactly the corridor width as followed', () => {
const track = [north(47), north(47.001)];
const offset = CORRIDOR_M / 75_800; // degrees of longitude for the corridor width at 47° N
const beside = track.map((point) => ({ lat: point.lat, lng: point.lng + offset * 0.99 }));
expect(compareRoutes(track, beside).withinCorridor).toBe(1);
});
it('PR1-05A: compares a one-point suggestion by plain distance', () => {
const result = compareRoutes([north(47), north(47.001)], [north(47)]);
expect(result.maxDeviationM).toBeGreaterThan(100);
});
});
@@ -0,0 +1,188 @@
/**
* Turning an imported track into control points a tour can be planned through,
* and measuring how far the line routed through them strays from the track.
*
* A recorded track is thousands of points, most of them noise of the walk, and
* none of them says where the walker meant to go. The points kept here are the
* ones the track's shape needs: a Douglas-Peucker pass keeps the bends, and a
* longest-gap rule adds a point wherever the track runs on too long without
* one, so the router is not left to guess across kilometres. Nothing here
* claims the result is the same tour; the comparison says how close it is.
*/
export interface TrackPoint {
lat: number;
lng: number;
}
export interface ControlPointOptions {
/** The most control points to keep, start and end included. */
maxPoints?: number;
/** The longest stretch of track, in metres, left without a control point. */
maxGapM?: number;
/** Control points closer than this, in metres, are merged (start and end stay). */
minSpacingM?: number;
}
export interface RouteComparison {
/** Length of the imported track, in kilometres. */
originalKm: number;
/** Length of the routed suggestion, in kilometres. */
suggestionKm: number;
/** The farthest any point of the track lies from the suggestion, in metres. */
maxDeviationM: number;
/** The share of the track within {@link CORRIDOR_M} of the suggestion, from 0 to 1. */
withinCorridor: number;
}
/** How far from the suggestion a stretch of track still counts as followed, in metres. */
export const CORRIDOR_M = 50;
/** Below this share of the track in the corridor the suggestion is called a different route. */
export const FAITHFUL_SHARE = 0.85;
const EARTH_M = 6_371_000;
const RAD = Math.PI / 180;
/** The track's points compared against the suggestion, at most; a long track is sampled evenly. */
const COMPARE_SAMPLES = 600;
/** Points on a local plane in metres, good enough over the span of one tour. */
function projector(points: readonly TrackPoint[]) {
const meanLat = points.reduce((sum, p) => sum + p.lat, 0) / Math.max(points.length, 1);
const kx = Math.cos(meanLat * RAD) * EARTH_M * RAD;
const ky = EARTH_M * RAD;
return (p: TrackPoint): [number, number] => [p.lng * kx, p.lat * ky];
}
function distanceToSegment(p: [number, number], a: [number, number], b: [number, number]): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const length2 = dx * dx + dy * dy;
const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
/** The length of a line, in metres. */
export function lineLengthMeters(points: readonly TrackPoint[]): number {
const project = projector(points);
let total = 0;
for (let i = 1; i < points.length; i += 1) {
const [ax, ay] = project(points[i - 1]);
const [bx, by] = project(points[i]);
total += Math.hypot(bx - ax, by - ay);
}
return total;
}
/** Douglas-Peucker on projected points: the indices a line of the given tolerance keeps. */
function simplify(xy: readonly [number, number][], tolerance: number): number[] {
const keep = new Uint8Array(xy.length);
keep[0] = 1;
keep[xy.length - 1] = 1;
const stack: Array<[number, number]> = [[0, xy.length - 1]];
for (let range = stack.pop(); range; range = stack.pop()) {
const [first, last] = range;
let farthest = -1;
let farthestDistance = tolerance;
for (let i = first + 1; i < last; i += 1) {
const distance = distanceToSegment(xy[i], xy[first], xy[last]);
if (distance > farthestDistance) {
farthest = i;
farthestDistance = distance;
}
}
if (farthest !== -1) {
keep[farthest] = 1;
stack.push([first, farthest], [farthest, last]);
}
}
return xy.flatMap((_, index) => (keep[index] ? [index] : []));
}
/** Adds a kept index wherever the track runs longer than `maxGapM` between two kept ones. */
function fillGaps(kept: number[], along: readonly number[], maxGapM: number): number[] {
const result: number[] = [kept[0]];
for (let k = 1; k < kept.length; k += 1) {
const from = kept[k - 1];
const to = kept[k];
let last = from;
for (let i = from + 1; i < to; i += 1) {
if (along[i] - along[last] >= maxGapM && along[to] - along[i] >= maxGapM / 4) {
result.push(i);
last = i;
}
}
result.push(to);
}
return result;
}
/** Drops kept indices closer than `minSpacingM` along the track to the one before; the end always stays. */
function spaceOut(kept: number[], along: readonly number[], minSpacingM: number): number[] {
const result: number[] = [kept[0]];
for (let k = 1; k < kept.length - 1; k += 1) {
if (along[kept[k]] - along[result[result.length - 1]] >= minSpacingM) result.push(kept[k]);
}
const end = kept[kept.length - 1];
if (result.length > 1 && along[end] - along[result[result.length - 1]] < minSpacingM) result.pop();
result.push(end);
return result;
}
/**
* The control points a tour planned from this track would run through: start
* and end, the bends the track's shape needs, and a point wherever it runs on
* longer than `maxGapM` without one. A tolerance that keeps too many is widened
* until the points fit `maxPoints`. A track with fewer than two points has none.
*/
export function deriveControlPoints(track: readonly TrackPoint[], options: ControlPointOptions = {}): TrackPoint[] {
const { maxPoints = 25, maxGapM = 2_500, minSpacingM = 150 } = options;
if (track.length < 2) return [];
const project = projector(track);
const xy = track.map(project);
const along = [0];
for (let i = 1; i < xy.length; i += 1)
along.push(along[i - 1] + Math.hypot(xy[i][0] - xy[i - 1][0], xy[i][1] - xy[i - 1][1]));
let tolerance = 25;
let kept: number[] = [];
for (let round = 0; round < 24; round += 1) {
kept = spaceOut(fillGaps(simplify(xy, tolerance), along, maxGapM), along, minSpacingM);
if (kept.length <= maxPoints) break;
tolerance *= 1.4;
}
if (kept.length > maxPoints) {
// Even a wide tolerance keeps too many (a very long track): evenly along it instead.
const total = along[along.length - 1];
kept = Array.from({ length: maxPoints }, (_, n) => {
const target = (total * n) / (maxPoints - 1);
return Math.max(
0,
along.findIndex((distance) => distance >= target)
);
});
}
return kept.map((index) => ({ lat: track[index].lat, lng: track[index].lng }));
}
/** How closely the suggestion follows the track: lengths, the farthest stray and the share inside the corridor. */
export function compareRoutes(track: readonly TrackPoint[], suggestion: readonly TrackPoint[]): RouteComparison {
const project = projector([...track, ...suggestion]);
const line = suggestion.map(project);
const step = Math.max(1, Math.ceil(track.length / COMPARE_SAMPLES));
const samples = track.filter((_, index) => index % step === 0 || index === track.length - 1).map(project);
let maxDeviationM = 0;
let within = 0;
for (const point of samples) {
let nearest = Infinity;
for (let i = 1; i < line.length && nearest > 0; i += 1)
nearest = Math.min(nearest, distanceToSegment(point, line[i - 1], line[i]));
if (line.length === 1) nearest = Math.hypot(point[0] - line[0][0], point[1] - line[0][1]);
maxDeviationM = Math.max(maxDeviationM, nearest);
if (nearest <= CORRIDOR_M) within += 1;
}
return {
originalKm: lineLengthMeters(track) / 1000,
suggestionKm: lineLengthMeters(suggestion) / 1000,
maxDeviationM,
withinCorridor: samples.length ? within / samples.length : 0,
};
}
@@ -0,0 +1,126 @@
import type { TourMaxHikingDifficulty } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { freeLegBetween } from '../planner/tourRouteActions';
import { routeWalkingTour } from '../planner/tourRouting';
import { paddleFieldsOf } from '../planner/usePaddleFields';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
/** The grade a planned version routes with when the import carries none: the paths a T3 walk may take. */
const DEFAULT_DIFFICULTY: TourMaxHikingDifficulty = 3;
type GpxConversionStatus = 'idle' | 'analyzing' | 'ready' | 'failed';
interface ConversionState {
/** The import the analysis belongs to; another import shown means it is idle again. */
placeId: number | null;
status: GpxConversionStatus;
points: TrackPoint[];
line: [number, number][] | null;
comparison: RouteComparison | null;
/** Why it failed: the track is too short to plan from, or the router found no way. */
error: 'track' | 'routing' | null;
}
const IDLE: ConversionState = { placeId: null, status: 'idle', points: [], line: null, comparison: null, error: null };
/**
* Planning an editable tour from an import shown in the tour planner (PR1-05A/B).
* The analysis starts only when asked: control points from the track, a route
* through them by the planner's own router, and how far that route strays from
* the track. Nothing is stored. Adopting hands the points to the planner as a
* new draft that remembers the import; only saving that draft creates a tour,
* and the import stays as it is.
*/
export function useGpxConversion(planner: TourPlannerController) {
const [state, setState] = useState<ConversionState>(IDLE);
const abort = useRef<AbortController | null>(null);
const tour = planner.mode.type === 'view-gpx' ? (planner.readOnlyGpxTour?.tour ?? null) : null;
const track = planner.readOnlyGpxAnalysis?.routeCoordinates ?? null;
const current = tour !== null && state.placeId === tour.place_id ? state : IDLE;
const difficulty = (tour?.max_hiking_difficulty ?? DEFAULT_DIFFICULTY) as TourMaxHikingDifficulty;
// A paddle import is planned as a paddle tour is: every leg drawn by hand, no router asked.
const paddle = tour?.tour_type === 'kayak';
useEffect(() => () => abort.current?.abort(), []);
const analyze = useCallback(async () => {
if (!tour || !track) return;
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
const trackPoints = track.map(([lat, lng]) => ({ lat, lng }));
const points = deriveControlPoints(trackPoints);
const base = { ...IDLE, placeId: tour.place_id, points };
if (points.length < 2) {
setState({ ...base, status: 'failed', error: 'track' });
return;
}
setState({ ...base, status: 'analyzing' });
const legs = paddle
? points.map((point, index) => ({
...point,
fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null,
}))
: points;
const routed = await routeWalkingTour(legs, controller.signal, difficulty);
if (controller.signal.aborted) return;
if (!routed) {
setState({ ...base, status: 'failed', error: 'routing' });
return;
}
const suggestion = routed.coordinates.map(([lat, lng]) => ({ lat, lng }));
setState({
...base,
status: 'ready',
line: routed.coordinates,
comparison: compareRoutes(trackPoints, suggestion),
});
}, [difficulty, paddle, tour, track]);
const cancel = useCallback(() => {
abort.current?.abort();
setState(IDLE);
}, []);
const { startDerivedTour } = planner;
/**
* The suggestion as a draft in the planner: the import planned over in place
* (`replace`), or a new tour beside it. True once the planner took it.
*/
const adopt = useCallback(
(how: 'replace' | 'new') => {
if (!tour || current.status !== 'ready') return false;
const started = startDerivedTour({
name: tour.name,
description: tour.description ?? null,
website: tour.website ?? null,
difficulty,
points: current.points,
sourcePlaceId: tour.place_id,
inPlace: how === 'replace',
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
});
if (started) setState(IDLE);
return started;
},
[current.points, current.status, difficulty, paddle, startDerivedTour, tour]
);
return useMemo(
() => ({
...current,
/** The import shown can be planned from: it has a track and the user may add tours. */
available: tour !== null && (track?.length ?? 0) >= 2 && planner.canEdit,
difficulty,
/** The import is a paddle tour: its version is drawn with straight legs, not routed. */
paddle,
analyze,
cancel,
adopt,
}),
[adopt, analyze, cancel, current, difficulty, paddle, planner.canEdit, tour, track]
);
}
export type GpxConversion = ReturnType<typeof useGpxConversion>;
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
/**
* A figure as a badge holding its value as a white badge, the planner's
* badge-in-badge look: the wind of the day, a weir's height, an access rule.
* What it means exactly can sit in its tooltip.
*/
export function FigureBadge({
label,
value,
icon,
info,
tone = 'neutral',
testId,
}: {
label?: string;
value: string;
icon?: ReactNode;
info?: string;
/** `warning` colours the value: a rule that forbids, a way recorded as private. */
tone?: 'neutral' | 'warning';
testId?: string;
}) {
const badge = (
<span
data-testid={testId}
className="inline-flex h-[24px] min-w-0 flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-secondary pe-[3px] ps-[3px] text-content-muted"
style={fs(10.5, 'body')}
>
{label && <span className="ps-1">{label}</span>}
<span
className={`inline-flex h-[18px] min-w-0 flex-none items-center gap-1 rounded-full bg-surface-card px-1.5 font-geist font-semibold tabular-nums shadow-sm ${tone === 'warning' ? 'text-warning' : 'text-content'}`}
style={fs(11)}
>
{icon}
<span className="truncate">{value}</span>
</span>
</span>
);
return info ? <Tooltip label={info}>{badge}</Tooltip> : badge;
}
@@ -0,0 +1,501 @@
// FE-PADDLE-DECIDE-001 to FE-PADDLE-DECIDE-016: the decision panel of one checklist entry (PAD-06).
// FE-PADDLE-DECIDE-017 to FE-PADDLE-DECIDE-020: the panel below a paddle object's map card, wired to the checklist.
import type { ComponentProps } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ObstacleDecision, ObstacleDecisionRecord, TourObstacleCheck, TourObstacleEntry } from '@trek/shared';
import { buildSettings } from '../../../../tests/helpers/factories';
import { earlierEntry, obstacleCheck, obstacleEntry } from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { ChecklistObstaclePanel, ObstacleDecisionPanel } from './ObstacleDecisionPanel';
import { formatCheckDate } from './paddleCheckModel';
import type { PaddleChecklist } from './usePaddleChecklist';
/** What the panel says, as shared/src/i18n/en/tours.ts words it. */
const SAYS = {
bans: 'OpenStreetMap records a ban or private ground here. A decision does not lift it.',
portageMissing: 'Choosing it plans no portage. Plan the carry on the map.',
gone: 'Missing from the data is no proof that the obstacle is gone.',
supLock: 'SUP: check a portage',
supLockInfo:
'For SUP, the German canoe federation and rescue organisations advise checking a permitted portage at locks rather than locking through. This is advice, not a ban everywhere, and local rules still apply.',
};
/** The four choices in the order the panel offers them. */
const CHOICES = ['Paddle through', 'Carry round', 'Go round', 'Not affected'];
const DECIDED_AT = '2026-09-20T10:00:00.000Z';
/** The day a decision was made, with the spaces the DOM queries read. */
const DECIDED_ON = formatCheckDate(DECIDED_AT, 'en').replace(/\s/g, ' ');
/** Paddling forbidden by the canoe tag. */
const BANNED: TourObstacleEntry['access'] = {
value: 'no',
source: 'canoe',
condition: null,
forbidden: true,
private: false,
};
function record(decision: ObstacleDecision, note: string | null = null): ObstacleDecisionRecord {
return { decision, note, decided_at: DECIDED_AT, decided_by: 1 };
}
/** The weir on the route, decided about. */
function decidedEntry(decision: ObstacleDecision = 'pass', note: string | null = null): TourObstacleEntry {
return obstacleEntry({ status: 'decided', decision: record(decision, note) });
}
type PanelProps = ComponentProps<typeof ObstacleDecisionPanel>;
const onDecide = vi.fn<PanelProps['onDecide']>();
const onNote = vi.fn<PanelProps['onNote']>(() => Promise.resolve(true));
const onClear = vi.fn<PanelProps['onClear']>();
const onKeep = vi.fn<PanelProps['onKeep']>();
/** Trip member 1 is Anna; anyone else has left the trip. */
const memberName = (id: number | null) => (id === 1 ? 'Anna' : null);
/** The panel for an entry a paddler may decide about, nothing to move on to and no portage to plan. */
function panel(entry: TourObstacleEntry, props: Partial<PanelProps> = {}) {
return (
<ObstacleDecisionPanel
entry={entry}
canDecide
saving={false}
memberName={memberName}
onDecide={onDecide}
onNote={onNote}
onClear={onClear}
onKeep={onKeep}
onNext={null}
onPlanPortage={null}
{...props}
/>
);
}
const renderPanel = (entry: TourObstacleEntry, props: Partial<PanelProps> = {}) => render(panel(entry, props));
/** The notices the panel shows; it holds one at least. */
const notices = () => screen.getByTestId('obstacle-notices');
/**
* A checklist as usePaddleChecklist hands it to the panel: ready, open to
* decisions, nothing saving, entries found by their refs, every action a spy.
*/
function checklistOf(
check: TourObstacleCheck | null = obstacleCheck(),
overrides: Partial<PaddleChecklist> = {}
): PaddleChecklist {
return {
status: 'ready',
tour: null,
check,
summary: null,
stale: false,
dirty: false,
canDecide: true,
canPlanPortage: true,
savingKey: null,
selectedKey: null,
dialogKey: null,
entryForRef: (ref) => check?.entries.find((entry) => entry.refs.includes(ref)) ?? null,
select: vi.fn<PaddleChecklist['select']>(),
selectOwn: vi.fn<PaddleChecklist['selectOwn']>(),
closeDialog: vi.fn<PaddleChecklist['closeDialog']>(),
nextOf: () => null,
next: vi.fn<PaddleChecklist['next']>(),
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve(true)),
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve(true)),
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve(true)),
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve(true)),
memberName,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
seedStore(useSettingsStore, { settings: buildSettings() });
});
describe('ObstacleDecisionPanel', () => {
it('FE-PADDLE-DECIDE-001: an open entry offers the four choices as one radio group named after it, none checked', () => {
const { rerender } = renderPanel(obstacleEntry());
const group = screen.getByRole('radiogroup', { name: 'Wehr Regendorf' });
const radios = within(group).getAllByRole('radio');
expect(radios.map((radio) => radio.textContent)).toEqual(CHOICES);
for (const radio of radios) {
expect(radio).toHaveAttribute('aria-checked', 'false');
expect(radio).toBeEnabled();
}
// Nothing decided yet: no note to add, nothing to remove, nothing to warn about.
expect(screen.queryByRole('button', { name: 'Note' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove decision' })).not.toBeInTheDocument();
expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument();
// An unnamed object names the group by its kind.
rerender(panel(obstacleEntry({ name: null, kind: 'dam' })));
expect(screen.getByRole('radiogroup', { name: 'Dam' })).toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-002: a choice decides; the decided one is filled and checked, and choosing it again sends nothing', () => {
const { rerender } = renderPanel(obstacleEntry());
fireEvent.click(screen.getByRole('radio', { name: 'Carry round' }));
expect(onDecide).toHaveBeenCalledTimes(1);
expect(onDecide).toHaveBeenCalledWith('portage');
rerender(panel(decidedEntry('portage')));
const chosen = screen.getByRole('radio', { name: 'Carry round' });
expect(chosen).toHaveAttribute('aria-checked', 'true');
expect(chosen).toHaveClass('bg-accent', 'text-accent-text');
expect(screen.getByRole('radio', { name: 'Paddle through' })).toHaveAttribute('aria-checked', 'false');
expect(screen.getByRole('radio', { name: 'Paddle through' })).not.toHaveClass('bg-accent');
fireEvent.click(chosen);
expect(onDecide).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('radio', { name: 'Not affected' }));
expect(onDecide).toHaveBeenLastCalledWith('not_affected');
});
it('FE-PADDLE-DECIDE-003: in review the earlier choice keeps a dashed ring, nothing is checked, the reason shows', () => {
renderPanel(obstacleEntry({ status: 'review', changed: ['route'], decision: record('pass') }));
// Named with its review state too, so it is told apart by more than its ring.
const earlier = screen.getByRole('radio', { name: 'Paddle through, Check again' });
expect(earlier).toHaveAttribute('aria-checked', 'false');
expect(earlier).toHaveClass('border-dashed');
expect(earlier).not.toHaveClass('bg-accent');
for (const name of CHOICES.slice(1)) {
expect(screen.getByRole('radio', { name })).not.toHaveClass('border-dashed');
}
expect(screen.getByTestId('obstacle-decision-panel')).toHaveTextContent('Route changed');
// The earlier choice can be made again, against the route as it is now.
fireEvent.click(earlier);
expect(onDecide).toHaveBeenCalledWith('pass');
});
it('FE-PADDLE-DECIDE-004: a ban or private ground in the data stays a notice after every decision', () => {
const { rerender } = renderPanel(obstacleEntry({ access: BANNED }));
expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument();
rerender(panel(obstacleEntry({ access: BANNED, status: 'decided', decision: record('pass') })));
expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument();
const privateGround = { ...BANNED, value: 'private', forbidden: false, private: true };
rerender(panel(obstacleEntry({ access: privateGround, status: 'decided', decision: record('not_affected') })));
expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-005: a lock advised for SUP says so, with the explanation behind an info dot', () => {
const lock = { kind: 'lock' as const, name: 'Schleuse Regensburg' };
const { rerender } = renderPanel(obstacleEntry({ ...lock, advice: 'sup_lock_portage' }));
expect(within(notices()).getByText(SAYS.supLock)).toBeInTheDocument();
expect(within(notices()).getByRole('button', { name: SAYS.supLock })).toHaveAccessibleDescription(SAYS.supLockInfo);
rerender(panel(obstacleEntry(lock)));
expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-006: carrying round with no portage planned yet says so and offers to plan it', () => {
const onPlanPortage = vi.fn<() => void>();
const todo = obstacleEntry({ status: 'todo', decision: record('portage') });
const { rerender } = renderPanel(todo, { onPlanPortage });
expect(within(notices()).getByText(SAYS.portageMissing)).toBeInTheDocument();
fireEvent.click(within(notices()).getByRole('button', { name: 'Plan the portage' }));
expect(onPlanPortage).toHaveBeenCalledTimes(1);
// Without a way to plan it the notice stays, without the action.
rerender(panel(todo));
expect(within(notices()).getByText(SAYS.portageMissing)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Plan the portage' })).not.toBeInTheDocument();
// Going round while the route still passes is no missing portage.
rerender(panel(obstacleEntry({ status: 'todo', decision: record('reroute') }), { onPlanPortage }));
expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-007: never more than two notices; the ban and the missing portage come before the SUP advice', () => {
renderPanel(
obstacleEntry({
kind: 'lock',
advice: 'sup_lock_portage',
access: BANNED,
status: 'todo',
decision: record('portage'),
}),
{ onPlanPortage: vi.fn<() => void>() }
);
const shown = notices();
expect(within(shown).getByText(SAYS.bans)).toBeInTheDocument();
expect(within(shown).getByText(SAYS.portageMissing)).toBeInTheDocument();
expect(within(shown).queryByText(SAYS.supLock)).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-008: the check on site the data records reads as a month, and as unknown when there is none', () => {
const { rerender } = renderPanel(obstacleEntry({ local_check: '2025-06' }));
const badge = screen.getByTestId('obstacle-on-site');
expect(badge).toHaveTextContent('On site');
expect(badge).toHaveTextContent(formatCheckDate('2025-06', 'en').replace(/\s/g, ' '));
rerender(panel(obstacleEntry()));
expect(screen.getByTestId('obstacle-on-site')).toHaveTextContent('unknown');
});
it('FE-PADDLE-DECIDE-009: a decision takes a note of two rows and at most 500 characters, saved trimmed', async () => {
renderPanel(decidedEntry());
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
const box = screen.getByRole('textbox', { name: 'Note' });
expect(box).toHaveAttribute('rows', '2');
expect(box).toHaveAttribute('maxlength', '500');
expect(box).toHaveFocus();
fireEvent.change(box, { target: { value: ' Links an der Bootsrutsche ' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onNote).toHaveBeenCalledTimes(1);
expect(onNote).toHaveBeenCalledWith('Links an der Bootsrutsche');
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
});
it('FE-PADDLE-DECIDE-021: a note whose save failed stays in the editor with its text', async () => {
onNote.mockResolvedValueOnce(false);
renderPanel(decidedEntry());
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: 'Rechts umtragen' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(onNote).toHaveBeenCalledWith('Rechts umtragen'));
await Promise.resolve();
expect(screen.getByRole('textbox', { name: 'Note' })).toHaveValue('Rechts umtragen');
});
it('FE-PADDLE-DECIDE-010: a saved note reads clamped; Cancel and an unchanged save keep it, emptying it removes it', () => {
renderPanel(decidedEntry('pass', 'Bootsrutsche am linken Ufer'));
expect(screen.getByText('Bootsrutsche am linken Ufer')).toHaveClass('line-clamp-3');
expect(screen.queryByRole('button', { name: 'Note' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
const box = screen.getByRole('textbox', { name: 'Note' });
expect(box).toHaveValue('Bootsrutsche am linken Ufer');
fireEvent.change(box, { target: { value: 'Anders' } });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onNote).not.toHaveBeenCalled();
expect(screen.getByText('Bootsrutsche am linken Ufer')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onNote).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: ' ' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onNote).toHaveBeenCalledWith(null);
});
it('FE-PADDLE-DECIDE-011: Escape in the note ends the edit and goes no further, so the map card around it stays open', () => {
const parent = vi.fn<() => void>();
const onDocument = vi.fn<(event: KeyboardEvent) => void>();
document.addEventListener('keydown', onDocument);
try {
render(<div onKeyDown={parent}>{panel(decidedEntry())}</div>);
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
const box = screen.getByRole('textbox', { name: 'Note' });
// Any other key reaches the card and the page as always.
fireEvent.keyDown(box, { key: 'a' });
expect(parent).toHaveBeenCalledTimes(1);
expect(onDocument).toHaveBeenCalledTimes(1);
const delivered = fireEvent.keyDown(box, { key: 'Escape' });
expect(delivered).toBe(false);
expect(parent).toHaveBeenCalledTimes(1);
expect(onDocument).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(onNote).not.toHaveBeenCalled();
} finally {
document.removeEventListener('keydown', onDocument);
}
});
it('FE-PADDLE-DECIDE-012: the foot says who decided when, removes the decision and moves on to the next entry', () => {
const onNext = vi.fn<() => void>();
const { rerender } = renderPanel(decidedEntry(), { onNext });
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
expect(onClear).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onNext).toHaveBeenCalledTimes(1);
// Someone who left the trip leaves the date alone; without a next entry there is no way on.
rerender(panel(obstacleEntry({ status: 'decided', decision: { ...record('pass'), decided_by: 7 } })));
expect(screen.getByText(`Decided ${DECIDED_ON}`)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument();
// An open entry moves on all the same.
rerender(panel(obstacleEntry(), { onNext }));
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
expect(screen.queryByText(/^Decided/)).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-013: an object the data no longer holds offers keeping or removing its decision, no choices', () => {
renderPanel(earlierEntry());
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
expect(screen.getByText(SAYS.gone)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Keep decision' }));
expect(onKeep).toHaveBeenCalledTimes(1);
// One way to remove it: the foot leaves it to the actions.
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
expect(onClear).toHaveBeenCalledTimes(1);
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-014: an earlier decision the route no longer meets has no choices to make, yet can be removed', () => {
renderPanel(earlierEntry({ status: 'off_route' }));
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
expect(screen.queryByText(SAYS.gone)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Keep decision' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
expect(onClear).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-DECIDE-015: a viewer reads the panel: the choices, the note and removing are off, moving on is not', () => {
const onNext = vi.fn<() => void>();
renderPanel(decidedEntry('pass', 'Bootsrutsche'), { canDecide: false, onNext });
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
fireEvent.click(screen.getByRole('radio', { name: 'Go round' }));
expect(onDecide).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Remove decision' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
});
it('FE-PADDLE-DECIDE-016: while its change saves the choices wait, and a viewer cannot keep or remove a vanished one', () => {
const { rerender } = renderPanel(obstacleEntry(), { saving: true });
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
expect(screen.getByTestId('obstacle-decision-panel')).toHaveAttribute('aria-busy', 'true');
rerender(panel(earlierEntry(), { canDecide: false }));
expect(screen.getByRole('button', { name: 'Keep decision' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Remove decision' })).toBeDisabled();
});
});
describe('ChecklistObstaclePanel', () => {
it('FE-PADDLE-DECIDE-017: shows nothing without a checklist or for an object it does not list, and stays while it reads again', () => {
const { container, rerender } = render(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/2" />);
expect(container).toBeEmptyDOMElement();
rerender(<ChecklistObstaclePanel checklist={checklistOf(null, { status: 'loading' })} featureRef="node/1" />);
expect(container).toBeEmptyDOMElement();
// Reading again keeps the panel open, locked, so a note being written is not lost.
rerender(
<ChecklistObstaclePanel
checklist={checklistOf(obstacleCheck(), { status: 'loading', canDecide: false })}
featureRef="node/1"
/>
);
expect(screen.getByTestId('obstacle-decision-panel')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Paddle through' })).toBeDisabled();
rerender(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/1" />);
expect(screen.getByTestId('obstacle-decision-panel')).toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-018: decides, plans the portage, notes, removes and moves on through the checklist', async () => {
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
const following = obstacleEntry({ key: 'node/2', refs: ['node/2'], name: 'Wehr Pielenhofen', along_m: 3400 });
const checklist = checklistOf(obstacleCheck({ entries: [entry, following] }), {
nextOf: (item) => (item.key === entry.key ? following : null),
});
render(<ChecklistObstaclePanel checklist={checklist} featureRef="node/1" />);
fireEvent.click(screen.getByRole('radio', { name: 'Go round' }));
expect(checklist.decide).toHaveBeenCalledWith(entry, 'reroute');
fireEvent.click(screen.getByRole('button', { name: 'Plan the portage' }));
expect(checklist.planPortage).toHaveBeenCalledWith(entry);
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: 'Rechts aussteigen' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(checklist.setNote).toHaveBeenCalledWith(entry, 'Rechts aussteigen');
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
expect(checklist.clear).toHaveBeenCalledWith(entry);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(checklist.next).toHaveBeenCalledWith(entry);
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
});
it('FE-PADDLE-DECIDE-022: someone who may not edit the tour is not offered to plan the portage', () => {
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
render(
<ChecklistObstaclePanel
checklist={checklistOf(obstacleCheck({ entries: [entry] }), { canPlanPortage: false, canDecide: false })}
featureRef="node/1"
/>
);
expect(screen.queryByRole('button', { name: 'Plan the portage' })).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-023: moving on keeps the focus in the panel when the next entry has no Next of its own', () => {
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
const following = obstacleEntry({ key: 'node/2', refs: ['node/2'], name: 'Wehr Pielenhofen', along_m: 3400 });
const check = obstacleCheck({ entries: [entry, following] });
let featureRef = 'node/1';
const checklist = checklistOf(check, {
nextOf: (item) => (item.key === entry.key ? following : null),
next: vi.fn<PaddleChecklist['next']>(() => {
featureRef = 'node/2';
}),
});
const { rerender } = render(<ChecklistObstaclePanel checklist={checklist} featureRef={featureRef} />);
const next = screen.getByRole('button', { name: 'Next' });
next.focus();
fireEvent.click(next);
rerender(<ChecklistObstaclePanel checklist={checklist} featureRef={featureRef} />);
expect(screen.getByTestId('obstacle-decision-panel')).toHaveFocus();
});
it('FE-PADDLE-DECIDE-019: a decision about an object the data no longer holds is kept through the checklist', () => {
const gone = earlierEntry();
const checklist = checklistOf(obstacleCheck({ entries: [obstacleEntry(), gone] }));
render(<ChecklistObstaclePanel checklist={checklist} featureRef="node/9" />);
fireEvent.click(screen.getByRole('button', { name: 'Keep decision' }));
expect(checklist.keep).toHaveBeenCalledWith(gone);
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument();
});
it('FE-PADDLE-DECIDE-020: the entry whose change is saving waits, as every entry does when the checklist cannot decide', () => {
const check = obstacleCheck({ entries: [obstacleEntry(), obstacleEntry({ key: 'node/2', refs: ['node/2'] })] });
const { rerender } = render(
<ChecklistObstaclePanel checklist={checklistOf(check, { savingKey: 'node/2' })} featureRef="node/1" />
);
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeEnabled();
rerender(<ChecklistObstaclePanel checklist={checklistOf(check, { savingKey: 'node/1' })} featureRef="node/1" />);
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
rerender(<ChecklistObstaclePanel checklist={checklistOf(check, { canDecide: false })} featureRef="node/1" />);
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
});
});
@@ -0,0 +1,472 @@
import {
OBSTACLE_DECISIONS,
type ObstacleDecision,
type ObstacleDecisionRecord,
type TourObstacleEntry,
} from '@trek/shared';
import {
ArrowRight,
Ban,
Check,
LifeBuoy,
MoveRight,
Pencil,
SearchX,
Split,
StickyNote,
Trash2,
Unlink,
X,
} from 'lucide-react';
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, InfoDot, TourNotice } from '../tourParts';
import { FigureBadge } from './FigureBadge';
import { ObstacleStatusBadges } from './PaddleCheckBadges';
import { PortageIcon } from './PaddleGlyph';
import { formatCheckDate } from './paddleCheckModel';
import type { PaddleChecklist } from './usePaddleChecklist';
type Translate = (key: string, params?: Record<string, string | number>) => string;
/** The longest note a decision keeps, as the checklist contract allows it. */
const NOTE_MAX = 500;
/** The notices an entry can call for, in the order they are shown: a ban first, since no decision lifts it. */
const NOTICE_ORDER = ['bans', 'portage', 'sup'] as const;
type NoticeId = (typeof NOTICE_ORDER)[number];
/** The most notices the panel shows at once. */
const MAX_NOTICES = 2;
const CHOICE_ICON: Record<ObstacleDecision, ReactNode> = {
pass: <MoveRight size={14} strokeWidth={2.2} className="rtl:rotate-180" />,
portage: <PortageIcon size={14} />,
reroute: <Split size={14} strokeWidth={2.2} />,
not_affected: <Unlink size={14} strokeWidth={2.2} />,
};
/** How a choice looks: decided, decided before the route or the data changed, or not chosen. */
type ChoiceLook = 'chosen' | 'earlier' | 'idle';
const CHOICE_LOOK: Record<ChoiceLook, string> = {
chosen: 'border-transparent bg-accent text-accent-text',
earlier: 'border-dashed border-accent bg-surface-card text-content',
idle: 'border-edge-faint bg-surface-card text-content-secondary enabled:hover:text-content',
};
const CHOICE =
'flex min-w-0 items-center gap-1.5 rounded-[10px] border px-2.5 py-1.5 text-start font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50';
const QUIET =
'rounded-sm font-semibold text-content-muted underline-offset-2 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:text-content enabled:hover:underline disabled:cursor-default disabled:opacity-40';
const ICON_BUTTON =
'grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:bg-surface-hover enabled:hover:text-content disabled:cursor-default disabled:opacity-40';
const SAVE_BUTTON =
'inline-flex h-7 items-center gap-1.5 rounded-full bg-accent px-2.5 font-semibold text-accent-text shadow-sm transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:opacity-90 disabled:cursor-default disabled:opacity-40';
/** The four choices as a radio group of two by two: the decided one filled, in review the earlier one dashed. */
function DecisionChoices({
entry,
label,
disabled,
onDecide,
}: {
entry: TourObstacleEntry;
label: string;
disabled: boolean;
onDecide: (decision: ObstacleDecision) => void;
}) {
const { t } = useTranslation();
const decided = entry.decision?.decision ?? null;
// In review the earlier choice no longer holds: it is shown, not checked, and can be made again.
const reviewing = entry.status === 'review';
const lookOf = (choice: ObstacleDecision): ChoiceLook => {
if (choice !== decided) return 'idle';
return reviewing ? 'earlier' : 'chosen';
};
return (
<div role="radiogroup" aria-label={label} className="grid grid-cols-2 gap-1.5">
{OBSTACLE_DECISIONS.map((choice) => {
const look = lookOf(choice);
return (
<button
key={choice}
type="button"
role="radio"
aria-checked={look === 'chosen'}
disabled={disabled}
onClick={() => {
if (look !== 'chosen') onDecide(choice);
}}
className={`${CHOICE} ${CHOICE_LOOK[look]}`}
style={fs(11.5, 'body')}
>
<span className="flex-none">{CHOICE_ICON[choice]}</span>
<span className="min-w-0 truncate">{t(`tours.paddle.check.decision.${choice}`)}</span>
{/* The earlier choice is told apart by more than its dashed ring. */}
{look === 'earlier' && <span className="sr-only">{`, ${t('tours.paddle.check.status.review')}`}</span>}
</button>
);
})}
</div>
);
}
/** Which notices an entry calls for, at most MAX_NOTICES of them. */
function noticesOf(entry: TourObstacleEntry): NoticeId[] {
const wanted: Record<NoticeId, boolean> = {
bans: entry.access.forbidden || entry.access.private,
portage: entry.status === 'todo' && entry.decision?.decision === 'portage',
sup: entry.advice === 'sup_lock_portage',
};
return NOTICE_ORDER.filter((id) => wanted[id]).slice(0, MAX_NOTICES);
}
/** What stays true whatever was chosen: a ban in the data, a portage not planned yet, the SUP advice at a lock. */
function PanelNotices({ entry, onPlanPortage }: { entry: TourObstacleEntry; onPlanPortage: (() => void) | null }) {
const { t } = useTranslation();
const shown = noticesOf(entry);
if (shown.length === 0) return null;
const notices: Record<NoticeId, ReactNode> = {
bans: (
<TourNotice key="bans" icon={<Ban size={13} strokeWidth={2} />}>
{t('tours.paddle.check.bansInfo')}
</TourNotice>
),
portage: (
<TourNotice key="portage" icon={<PortageIcon size={13} />}>
<div className="flex flex-col items-start gap-1.5">
<span>{t('tours.paddle.check.portageMissingInfo')}</span>
{onPlanPortage && (
<CardAction icon={<PortageIcon size={12} />} onClick={onPlanPortage}>
{t('tours.paddle.check.planPortage')}
</CardAction>
)}
</div>
</TourNotice>
),
sup: (
<TourNotice key="sup" icon={<LifeBuoy size={13} strokeWidth={2} />}>
<div className="flex items-center gap-1">
<span className="min-w-0">{t('tours.paddle.check.supLock')}</span>
<span className="-my-1 flex-none">
<InfoDot label={t('tours.paddle.check.supLock')} description={t('tours.paddle.check.supLockInfo')} />
</span>
</div>
</TourNotice>
),
};
return (
<div data-testid="obstacle-notices" className="flex flex-col gap-2">
{shown.map((id) => notices[id])}
</div>
);
}
/** An entry the data no longer holds: why that proves nothing, and keeping or removing its decision. */
function GoneActions({ disabled, onKeep, onClear }: { disabled: boolean; onKeep: () => void; onClear: () => void }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<TourNotice icon={<SearchX size={13} strokeWidth={2} />}>{t('tours.paddle.check.goneInfo')}</TourNotice>
<div className="flex flex-wrap items-center gap-1.5">
<CardAction icon={<Check size={12} strokeWidth={2.4} />} onClick={onKeep} disabled={disabled}>
{t('tours.paddle.check.keep')}
</CardAction>
<CardAction icon={<Trash2 size={12} strokeWidth={2.2} />} onClick={onClear} disabled={disabled}>
{t('tours.paddle.check.clear')}
</CardAction>
</div>
</div>
);
}
/** Writing the note in two rows; Escape drops the edit without closing the map card or the dialog around it. */
function NoteEditor({
draft,
disabled,
onChange,
onSave,
onCancel,
}: {
draft: string;
disabled: boolean;
onChange: (draft: string) => void;
onSave: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const box = useRef<HTMLTextAreaElement>(null);
// The note opens for writing, so it takes the focus once, when it opens.
useEffect(() => {
box.current?.focus();
}, []);
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== 'Escape') return;
// The map card and the dialog close on Escape; inside the note it only ends the edit.
event.preventDefault();
event.stopPropagation();
onCancel();
};
return (
<div className="flex flex-col gap-1.5">
<textarea
rows={2}
maxLength={NOTE_MAX}
ref={box}
value={draft}
aria-label={t('tours.paddle.check.note')}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
className="w-full resize-none rounded-[10px] border border-edge bg-surface-input px-2.5 py-1.5 text-content outline-none focus:border-accent"
style={fs(12, 'body')}
/>
<div className="flex items-center justify-end gap-1.5">
<CardAction icon={<X size={12} strokeWidth={2.4} />} onClick={onCancel}>
{t('common.cancel')}
</CardAction>
<button type="button" onClick={onSave} disabled={disabled} className={SAVE_BUTTON} style={fs(11.5, 'body')}>
<Check size={12} strokeWidth={2.4} aria-hidden="true" />
{t('common.save')}
</button>
</div>
</div>
);
}
/** The decision's note: read clamped with a way to edit it, or a way to add one. */
function NoteBlock({
note,
disabled,
onNote,
}: {
note: string | null;
disabled: boolean;
/** Resolves to whether the note was saved; a failed save keeps the text being written. */
onNote: (note: string | null) => Promise<boolean>;
}) {
const { t } = useTranslation();
// The text being written, or null while the note is only read.
const [draft, setDraft] = useState<string | null>(null);
const save = (text: string) => {
const next = text.trim() || null;
if (next === note) {
setDraft(null);
return;
}
void onNote(next).then((saved) => {
if (saved) setDraft(null);
});
};
if (draft !== null)
return (
<NoteEditor
draft={draft}
disabled={disabled}
onChange={setDraft}
onSave={() => save(draft)}
onCancel={() => setDraft(null)}
/>
);
if (!note)
return (
<div>
<CardAction icon={<StickyNote size={12} strokeWidth={2.2} />} onClick={() => setDraft('')} disabled={disabled}>
{t('tours.paddle.check.note')}
</CardAction>
</div>
);
return (
<div className="flex items-start gap-1.5">
<p
className="m-0 line-clamp-3 min-w-0 flex-1 break-words text-content-muted"
style={fs(11.5, 'body')}
title={note}
>
{note}
</p>
<Tooltip label={t('common.edit')}>
<button
type="button"
aria-label={t('common.edit')}
onClick={() => setDraft(note)}
disabled={disabled}
className={ICON_BUTTON}
>
<Pencil size={12} strokeWidth={2.2} aria-hidden="true" />
</button>
</Tooltip>
</div>
);
}
/** Who decided and when: the trip member by name while they are on the trip, otherwise the date alone. */
function decidedLine(
decision: ObstacleDecisionRecord,
memberName: (id: number | null) => string | null,
t: Translate,
locale: string
): string {
const date = formatCheckDate(decision.decided_at, locale);
const name = memberName(decision.decided_by);
return name ? t('tours.paddle.check.decidedBy', { date, name }) : t('tours.paddle.check.decidedAt', { date });
}
/** The foot of the panel: who decided when, removing the decision, and the way on to the next open entry. */
function MetaLine({
decision,
memberName,
clearable,
disabled,
onClear,
onNext,
}: {
decision: ObstacleDecisionRecord | null;
memberName: (id: number | null) => string | null;
clearable: boolean;
disabled: boolean;
onClear: () => void;
onNext: (() => void) | null;
}) {
const { t, locale } = useTranslation();
if (!decision && !onNext) return null;
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-content-faint" style={fs(10.5, 'body')}>
{decision && <span className="min-w-0">{decidedLine(decision, memberName, t, locale)}</span>}
{decision && clearable && (
<button type="button" onClick={onClear} disabled={disabled} className={QUIET}>
{t('tours.paddle.check.clear')}
</button>
)}
{onNext && (
<span className="ms-auto">
<CardAction icon={<ArrowRight size={12} strokeWidth={2.4} className="rtl:rotate-180" />} onClick={onNext}>
{t('tours.paddle.check.next')}
</CardAction>
</span>
)}
</div>
);
}
/**
* PAD-06: what the paddler decides about one obstacle of the checklist, below
* its map card or in its dialog. The four choices, the notices that stay
* whatever was chosen (a ban in the data, a portage not planned yet, the SUP
* advice at a lock), the check on site the data records, a note, and who
* decided when. An object the data no longer holds offers keeping or removing
* its decision instead of the choices. A decision is the paddler's choice,
* never a safety or legal clearance.
*/
export function ObstacleDecisionPanel({
entry,
canDecide,
saving,
memberName,
onDecide,
onNote,
onClear,
onKeep,
onNext,
onPlanPortage,
}: {
entry: TourObstacleEntry;
canDecide: boolean;
/** This entry's change is on its way to the server. */
saving: boolean;
memberName: (id: number | null) => string | null;
onDecide: (decision: ObstacleDecision) => void;
onNote: (note: string | null) => Promise<boolean>;
onClear: () => void;
onKeep: () => void;
/** Moves on to the next entry still asking for a look; null when there is none. */
onNext: (() => void) | null;
/** Starts planning the portage round the obstacle; null where that cannot be offered. */
onPlanPortage: (() => void) | null;
}) {
const { t, locale } = useTranslation();
const locked = !canDecide || saving;
const gone = entry.status === 'gone';
const label = entry.name ?? t(`tours.paddle.obstacle.kind.${entry.kind}`);
const root = useRef<HTMLDivElement>(null);
const movedOn = useRef(false);
// Moving on keeps the focus in the panel, also when the next entry has no Next button of its own.
useLayoutEffect(() => {
if (!movedOn.current) return;
movedOn.current = false;
if (!root.current?.contains(document.activeElement)) root.current?.focus({ preventScroll: true });
});
const next = onNext
? () => {
movedOn.current = true;
onNext();
}
: null;
return (
<div
ref={root}
tabIndex={-1}
data-testid="obstacle-decision-panel"
aria-busy={saving || undefined}
className="flex flex-col gap-2.5 outline-none"
>
<div className="flex flex-wrap items-center gap-1.5 empty:hidden">
<ObstacleStatusBadges entry={entry} />
</div>
{entry.relation !== null && <DecisionChoices entry={entry} label={label} disabled={locked} onDecide={onDecide} />}
{gone && <GoneActions disabled={locked} onKeep={onKeep} onClear={onClear} />}
<PanelNotices entry={entry} onPlanPortage={onPlanPortage} />
<div className="flex">
<FigureBadge
testId="obstacle-on-site"
label={t('tours.paddle.check.onSite')}
value={entry.local_check ? formatCheckDate(entry.local_check, locale) : t('tours.paddle.check.unknown')}
/>
</div>
{entry.decision && <NoteBlock key={entry.key} note={entry.decision.note} disabled={locked} onNote={onNote} />}
<MetaLine
decision={entry.decision}
memberName={memberName}
clearable={!gone}
disabled={locked}
onClear={onClear}
onNext={next}
/>
</div>
);
}
/** The decision panel of one checklist entry wired to the checklist; the map card and the entry dialog share it. */
export function ChecklistEntryPanel({ checklist, entry }: { checklist: PaddleChecklist; entry: TourObstacleEntry }) {
return (
<ObstacleDecisionPanel
entry={entry}
canDecide={checklist.canDecide}
saving={checklist.savingKey === entry.key}
memberName={checklist.memberName}
onDecide={(decision) => void checklist.decide(entry, decision)}
onNote={(note) => checklist.setNote(entry, note)}
onClear={() => void checklist.clear(entry)}
onKeep={() => void checklist.keep(entry)}
onNext={checklist.nextOf(entry) ? () => checklist.next(entry) : null}
onPlanPortage={checklist.canPlanPortage ? () => checklist.planPortage(entry) : null}
/>
);
}
/**
* The checklist's part of the map card of a paddle object (PAD-06): the
* decision panel of the entry the object belongs to, below its facts, or
* nothing while there is no checklist or it does not list the object.
*/
export function ChecklistObstaclePanel({ checklist, featureRef }: { checklist: PaddleChecklist; featureRef: string }) {
// While the checklist reads again it keeps showing, locked, so the panel open on an object stays open.
const entry = checklist.check ? checklist.entryForRef(featureRef) : null;
if (!entry) return null;
return (
<div className="mt-3 border-t border-edge-faint pt-3">
<ChecklistEntryPanel checklist={checklist} entry={entry} />
</div>
);
}
@@ -0,0 +1,159 @@
// FE-PADDLE-ENTRY-DIALOG-001 to FE-PADDLE-ENTRY-DIALOG-007: the dialog of a checklist entry the map holds no card for.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TourObstacleCheck, TourObstacleEntry } from '@trek/shared';
import { buildSettings } from '../../../../tests/helpers/factories';
import { earlierEntry, obstacleCheck, obstacleEntry } from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { ObstacleEntryDialog } from './ObstacleEntryDialog';
import type { PaddleChecklist } from './usePaddleChecklist';
const OSM = 'View in OpenStreetMap';
/**
* A checklist as usePaddleChecklist hands it to the dialog: ready, open to
* decisions, no dialog open unless told, every action a spy.
*/
function checklistOf(
check: TourObstacleCheck | null = obstacleCheck(),
overrides: Partial<PaddleChecklist> = {}
): PaddleChecklist {
return {
status: 'ready',
tour: null,
check,
summary: null,
stale: false,
dirty: false,
canDecide: true,
canPlanPortage: true,
savingKey: null,
selectedKey: null,
dialogKey: null,
entryForRef: (ref) => check?.entries.find((entry) => entry.refs.includes(ref)) ?? null,
select: vi.fn<PaddleChecklist['select']>(),
selectOwn: vi.fn<PaddleChecklist['selectOwn']>(),
closeDialog: vi.fn<PaddleChecklist['closeDialog']>(),
nextOf: () => null,
next: vi.fn<PaddleChecklist['next']>(),
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve(true)),
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve(true)),
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve(true)),
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve(true)),
memberName: (id) => (id === 1 ? 'Anna' : null),
...overrides,
};
}
/** The dialog opened for an entry the way the checklist opens it: by its key. */
function openFor(entry: TourObstacleEntry): PaddleChecklist {
const checklist = checklistOf(obstacleCheck({ entries: [entry] }), { dialogKey: entry.key });
render(<ObstacleEntryDialog checklist={checklist} />);
return checklist;
}
/** The head band of the dialog, apart from the panel in its body. */
function headerOf(dialog: HTMLElement): HTMLElement {
const header = dialog.querySelector('header');
if (!header) throw new Error('the dialog has no head band');
return header;
}
beforeEach(() => {
vi.clearAllMocks();
// No distance unit chosen: the dialog reads metric.
seedStore(useSettingsStore, { settings: buildSettings() });
});
describe('ObstacleEntryDialog', () => {
it('FE-PADDLE-ENTRY-DIALOG-001: stays closed without a dialog key, for a key the checklist lacks and without a checklist', () => {
const { rerender } = render(<ObstacleEntryDialog checklist={checklistOf()} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<ObstacleEntryDialog checklist={checklistOf(obstacleCheck(), { dialogKey: 'node/404' })} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<ObstacleEntryDialog checklist={checklistOf(null, { dialogKey: 'node/1' })} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-PADDLE-ENTRY-DIALOG-002: names the obstacle and its kind, how far along the route it lies, and links to OSM', () => {
openFor(obstacleEntry());
const header = headerOf(screen.getByRole('dialog', { name: 'Wehr Regendorf' }));
expect(header).toHaveTextContent('Weir');
expect(header).toHaveTextContent('1.2 km');
const link = within(header).getByRole('link', { name: OSM });
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/node/1');
expect(link).toHaveAttribute('target', '_blank');
expect(link.getAttribute('rel')).toContain('noreferrer');
});
it('FE-PADDLE-ENTRY-DIALOG-003: its body is the decision panel, wired to the checklist', () => {
const entry = obstacleEntry();
const checklist = openFor(entry);
const dialog = screen.getByRole('dialog', { name: 'Wehr Regendorf' });
expect(within(dialog).getByTestId('obstacle-decision-panel')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('radio', { name: 'Carry round' }));
expect(checklist.decide).toHaveBeenCalledWith(entry, 'portage');
});
it('FE-PADDLE-ENTRY-DIALOG-004: an object the data no longer holds gives no distance and offers keeping its decision', () => {
const gone = earlierEntry();
const checklist = openFor(gone);
const dialog = screen.getByRole('dialog', { name: 'Alte Sohlschwelle' });
const header = headerOf(dialog);
expect(header).not.toHaveTextContent('km');
expect(within(header).getByRole('link', { name: OSM })).toHaveAttribute(
'href',
'https://www.openstreetmap.org/node/9'
);
expect(within(dialog).queryByRole('radiogroup')).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep decision' }));
expect(checklist.keep).toHaveBeenCalledWith(gone);
});
it('FE-PADDLE-ENTRY-DIALOG-005: an unnamed obstacle is titled by its kind and links its nearest object', () => {
openFor(obstacleEntry({ key: 'lock:way/42', refs: ['way/42', 'way/43'], kind: 'lock', name: null, along_m: 800 }));
const header = headerOf(screen.getByRole('dialog', { name: 'Lock' }));
expect(within(header).getByRole('heading', { name: 'Lock' })).toBeInTheDocument();
expect(header).toHaveTextContent('800 m');
expect(within(header).getByRole('link', { name: OSM })).toHaveAttribute(
'href',
'https://www.openstreetmap.org/way/42'
);
});
it('FE-PADDLE-ENTRY-DIALOG-006: the distance follows the chosen unit', () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
openFor(obstacleEntry());
expect(headerOf(screen.getByRole('dialog', { name: 'Wehr Regendorf' }))).toHaveTextContent('0.7 mi');
});
it('FE-PADDLE-ENTRY-DIALOG-007: the close button and Escape close it, while Escape in the note only ends the note', () => {
const decided = obstacleEntry({
status: 'decided',
decision: { decision: 'pass', note: null, decided_at: '2026-09-20T10:00:00.000Z', decided_by: 1 },
});
const checklist = openFor(decided);
const dialog = screen.getByRole('dialog', { name: 'Wehr Regendorf' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Note' }));
fireEvent.keyDown(within(dialog).getByRole('textbox', { name: 'Note' }), { key: 'Escape' });
expect(within(dialog).queryByRole('textbox')).not.toBeInTheDocument();
expect(checklist.closeDialog).not.toHaveBeenCalled();
fireEvent.keyDown(document, { key: 'Escape' });
expect(checklist.closeDialog).toHaveBeenCalledTimes(1);
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }));
expect(checklist.closeDialog).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,75 @@
import type { TourObstacleEntry } from '@trek/shared';
import { ExternalLink } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT, PILL } from '../../shared/DialogShell';
import { ChecklistEntryPanel } from './ObstacleDecisionPanel';
import { ChecklistGlyph } from './PaddleCheckBadges';
import { osmUrlOf } from './paddleObstacleModel';
import type { PaddleChecklist } from './usePaddleChecklist';
/** The pills of the head band: how far along the route the object lies and the object on OpenStreetMap. */
function EntryPills({ entry }: { entry: TourObstacleEntry }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const ref = entry.refs[0];
return (
<>
{entry.along_m !== null && (
<span className={`${PILL} tabular-nums`}>{formatDistance(entry.along_m / 1000, unit)}</span>
)}
{ref && (
<a href={osmUrlOf(ref)} target="_blank" rel="noopener noreferrer" className={`${PILL} hover:underline`}>
{t('ferratas.viewInOsm')}
<ExternalLink size={12} strokeWidth={2.2} aria-hidden="true" />
</a>
)}
</>
);
}
/** The dialog for an entry the checklist holds: its head band, then the decision panel. */
function EntryDialog({ checklist, entry }: { checklist: PaddleChecklist; entry: TourObstacleEntry }) {
const { t } = useTranslation();
const labelId = useId();
const kind = t(`tours.paddle.obstacle.kind.${entry.kind}`);
return (
<DialogShell
onClose={checklist.closeDialog}
labelledBy={labelId}
width="narrow"
header={
<DialogHeader
tile={
<DialogTile>
<ChecklistGlyph kind={entry.kind} dashed={entry.relation === 'near'} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={checklist.closeDialog}
title={entry.name ?? kind}
sub={entry.name ? kind : undefined}
pills={<EntryPills entry={entry} />}
/>
}
>
<ChecklistEntryPanel checklist={checklist} entry={entry} />
</DialogShell>
);
}
/**
* PAD-06: one entry of the checklist in a narrow dialog, for an object the
* map holds no card for (offline, or an earlier decision the data or the
* route no longer carries): what it is, how far along the route it lies, its
* object on OpenStreetMap and the same decision panel the map card carries.
*/
export function ObstacleEntryDialog({ checklist }: { checklist: PaddleChecklist }) {
const { check, dialogKey } = checklist;
const entry = dialogKey === null ? null : (check?.entries.find((item) => item.key === dialogKey) ?? null);
if (!entry) return null;
return <EntryDialog checklist={checklist} entry={entry} />;
}
@@ -0,0 +1,421 @@
// PCB-001 to PCB-033: the badges of a paddle tour's checklist (PAD-06, PAD-09): the bench as one pill, how far its
// data reaches and how current it is, the ring a row's symbol sits in, where an entry stands and its paddling rule.
import { describe, expect, it } from 'vitest';
import type { ObstacleDecision, PaddleCheck, PaddleCheckData, TourObstacleEntry } from '@trek/shared';
import { earlierEntry, obstacleEntry, paddleBench } from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import {
ChecklistGlyph,
ObstacleAccessBadge,
ObstacleStatusBadges,
PaddleCheckDataLine,
PaddleCheckPill,
} from './PaddleCheckBadges';
import { formatCheckDate } from './paddleCheckModel';
const NOT_CLEARANCE = 'A checklist is no safety or legal clearance.';
const BANS_INFO = 'OpenStreetMap records a ban or private ground here. A decision does not lift it.';
const GONE_INFO = 'Missing from the data is no proof that the obstacle is gone.';
/** A conditional rule as OpenStreetMap writes it: shown as written, never evaluated. */
const CONDITION = 'no @ (Nov-Mar)';
/** The dates of a bench as the fixtures carry them, and as the badges word them in the suite's locale (en-US). */
const DATA_AS_OF = '2026-10-01T00:00:00.000Z';
const READ_AT = '2026-10-02T04:20:00.000Z';
const AS_OF = formatCheckDate(DATA_AS_OF, 'en-US');
const READ = formatCheckDate(READ_AT, 'en-US');
/** No paddling rule in the tags at all. */
const NO_RULE: TourObstacleEntry['access'] = {
value: null,
source: null,
condition: null,
forbidden: false,
private: false,
};
function decisionOf(decision: ObstacleDecision): TourObstacleEntry['decision'] {
return { decision, note: null, decided_at: '2026-10-10T10:00:00.000Z', decided_by: 1 };
}
/** What an element's tooltip says once it is hovered; the hover ends before it returns. */
async function tooltipOf(element: HTMLElement): Promise<string | null> {
fireEvent.mouseEnter(element);
const text = (await screen.findByRole('tooltip')).textContent;
fireEvent.mouseLeave(element);
return text;
}
function pillOf(check: PaddleCheck): HTMLElement {
render(<PaddleCheckPill check={check} />);
return screen.getByTestId('plan-tour-paddle-check');
}
interface Precedence {
id: string;
rule: string;
bench: Partial<PaddleCheck>;
value: string;
icon: string;
}
const PRECEDENCE: Precedence[] = [
{
id: 'PCB-002',
rule: 'unavailable data says so over every count and every ban',
bench: { data: 'unavailable', open: 2, review: 1, bans: 1 },
value: 'Unavailable',
icon: 'circle-dashed',
},
{
id: 'PCB-003',
rule: 'no data says so and never shows a count',
bench: { data: 'none', open: 2, decided: 3 },
value: 'No data',
icon: 'circle-dashed',
},
{
id: 'PCB-004',
rule: 'entries open or to check again come before a partial coverage, counted together',
bench: { data: 'partial', open: 1, review: 2 },
value: '3 open',
icon: 'list-checks',
},
{
id: 'PCB-005',
rule: 'partial data with nothing pending names the coverage',
bench: { data: 'partial', open: 0, decided: 2 },
value: 'Partial',
icon: 'list-checks',
},
{
id: 'PCB-006',
rule: 'data cut short with nothing pending names it',
bench: { data: 'limited', open: 0 },
value: 'Cut short',
icon: 'list-checks',
},
{
id: 'PCB-007',
rule: 'a route without known obstacles says none known, never clear',
bench: { entries: 0, open: 0 },
value: 'None known',
icon: 'list-checks',
},
{
id: 'PCB-008',
rule: 'every entry decided counts the decisions',
bench: { entries: 2, open: 0, decided: 2 },
value: '2 decided',
icon: 'list-checks',
},
];
describe('PaddleCheckPill', () => {
it('PCB-001: an open entry on covered data reads as open and is named by its counts and the caveat', () => {
const pill = pillOf(paddleBench());
expect(pill.textContent).toBe('1 open');
expect(pill).toHaveAttribute('aria-label', `1 open\n${NOT_CLEARANCE}`);
expect(pill.querySelector('svg.lucide-list-checks')).not.toBeNull();
});
it.each(PRECEDENCE)('$id: $rule', ({ bench, value, icon }) => {
const pill = pillOf(paddleBench(bench));
expect(pill.textContent).toBe(value);
expect(pill.querySelector(`svg.lucide-${icon}`)).not.toBeNull();
});
it('PCB-009: the tooltip lists the data, every count, the ban and that a checklist is no clearance', async () => {
const pill = pillOf(paddleBench({ data: 'partial', open: 2, review: 1, decided: 3, bans: 1 }));
const label = [
'Obstacle data: Partial',
'2 open',
'1 to check again',
'3 decided',
'1 with a ban',
BANS_INFO,
NOT_CLEARANCE,
].join('\n');
expect(pill.textContent).toBe('3 open');
expect(pill.querySelector('svg.lucide-ban')).not.toBeNull();
expect(pill).toHaveAttribute('aria-label', label);
expect(await tooltipOf(pill)).toBe(label);
});
});
interface Coverage {
id: string;
data: Exclude<PaddleCheckData, 'covered'>;
word: string;
info: string;
}
const COVERAGE: Coverage[] = [
{
id: 'PCB-011',
data: 'partial',
word: 'Partial',
info: `Obstacle data covers only part of the route (as of ${AS_OF}). Check the rest yourself.`,
},
{
id: 'PCB-012',
data: 'none',
word: 'No data',
info: 'No obstacle data for this area. Check weirs, locks and portages here yourself.',
},
{
id: 'PCB-013',
data: 'limited',
word: 'Cut short',
info: 'TREK shows only part of the objects here; the area holds more. Check the water yourself.',
},
{
id: 'PCB-014',
data: 'unavailable',
word: 'Unavailable',
info: 'The obstacle data could not be loaded. Check the water yourself.',
},
];
describe('PaddleCheckDataLine', () => {
it('PCB-010: covered data shows no coverage badge, the date OSM is current to and when TREK fetched it', async () => {
render(<PaddleCheckDataLine check={paddleBench({ data_as_of: DATA_AS_OF, read_at: READ_AT })} />);
expect(screen.queryByTestId('paddle-check-coverage')).not.toBeInTheDocument();
const asOf = screen.getByTestId('paddle-check-as-of');
expect(within(asOf).getByText('OSM as of')).toBeInTheDocument();
expect(within(asOf).getByText(AS_OF)).toBeInTheDocument();
const read = screen.getByTestId('paddle-check-read');
expect(within(read).getByText('Fetched')).toBeInTheDocument();
expect(within(read).getByText(READ)).toBeInTheDocument();
// Both sit in the one wrapping line.
expect(screen.getByTestId('paddle-check-data')).toContainElement(asOf);
expect(screen.getByTestId('paddle-check-data')).toContainElement(read);
expect(await tooltipOf(asOf)).toBe(`OpenStreetMap data as of ${AS_OF}, not a check on site.`);
});
it.each(COVERAGE)(
'$id: $data data names its coverage and says to check the water yourself',
async ({ data, word, info }) => {
render(<PaddleCheckDataLine check={paddleBench({ data, data_as_of: DATA_AS_OF })} />);
const coverage = screen.getByTestId('paddle-check-coverage');
expect(within(coverage).getByText('Obstacle data')).toBeInTheDocument();
expect(within(coverage).getByText(word)).toBeInTheDocument();
expect(await tooltipOf(coverage)).toBe(info);
}
);
it('PCB-015: an unknown date reads as unknown, and data never fetched shows no fetch date', async () => {
render(<PaddleCheckDataLine check={paddleBench({ data: 'partial', data_as_of: null, read_at: null })} />);
const asOf = screen.getByTestId('paddle-check-as-of');
expect(within(asOf).getByText('unknown')).toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-read')).not.toBeInTheDocument();
expect(await tooltipOf(asOf)).toBe('OpenStreetMap data of an unknown date, not a check on site.');
expect(await tooltipOf(screen.getByTestId('paddle-check-coverage'))).toBe(
'Obstacle data covers only part of the route (as of an unknown date). Check the rest yourself.'
);
});
});
describe('ChecklistGlyph', () => {
it('PCB-016: an obstacle on the route sits in a solid 24 px ring of its kind colour', () => {
render(<ChecklistGlyph kind="weir" />);
const ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.width).toBe('24px');
expect(ring.style.height).toBe('24px');
expect(ring.style.color).toBe('var(--map-paddle-obstacle)');
expect(ring.style.borderColor).toBe('var(--map-paddle-obstacle)');
expect(ring).toHaveClass('border-solid');
expect(ring).not.toHaveClass('border-dashed');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'weir');
});
it('PCB-017: an object only near the route gets a dashed ring at the size asked for; a lock gate shows the lock', () => {
render(<ChecklistGlyph kind="lock_gate" dashed size={20} />);
const ring = screen.getByTestId('paddle-check-glyph');
expect(ring).toHaveClass('border-dashed');
expect(ring.style.width).toBe('20px');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock');
});
it('PCB-018: an own portage, an own lock and an access rule take the colour and symbol of their kind', () => {
const { rerender } = render(<ChecklistGlyph kind="portage" />);
let ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.borderColor).toBe('var(--map-portage)');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'portage');
rerender(<ChecklistGlyph kind="lock" />);
ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.borderColor).toBe('var(--map-paddle-obstacle)');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock');
rerender(<ChecklistGlyph kind="access" />);
ring = screen.getByTestId('paddle-check-glyph');
expect(ring.style.borderColor).toBe('var(--map-paddle-access)');
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'access');
});
});
interface EarlierStatus {
id: string;
status: 'off_route' | 'unknown';
decision: ObstacleDecision;
chosen: string;
word: string;
}
const EARLIER: EarlierStatus[] = [
{
id: 'PCB-027',
status: 'off_route',
decision: 'not_affected',
chosen: 'Not affected',
word: 'No longer on the route',
},
{ id: 'PCB-028', status: 'unknown', decision: 'reroute', chosen: 'Go round', word: 'Not comparable' },
];
describe('ObstacleStatusBadges', () => {
it('PCB-019: an open entry reads as open and nothing more', () => {
render(<ObstacleStatusBadges entry={obstacleEntry()} />);
const badges = screen.getAllByTestId('paddle-check-status');
expect(badges).toHaveLength(1);
expect(badges[0]?.textContent).toBe('Open');
});
it('PCB-020: a decided entry shows the decision alone', () => {
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'decided', decision: decisionOf('not_affected') })} />);
expect(screen.getByTestId('paddle-check-status').textContent).toBe('Not affected');
});
it('PCB-021: an entry counted as decided that carries no decision shows no empty badge', () => {
const { container } = render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'decided', decision: null })} />);
expect(container).toBeEmptyDOMElement();
});
it('PCB-022: a choice whose plan is missing names the choice and what is missing', () => {
const { rerender } = render(
<ObstacleStatusBadges entry={obstacleEntry({ status: 'todo', decision: decisionOf('portage') })} />
);
let badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Carry round')).toBeInTheDocument();
expect(within(badge).getByText('No portage yet')).toBeInTheDocument();
rerender(<ObstacleStatusBadges entry={obstacleEntry({ status: 'todo', decision: decisionOf('reroute') })} />);
badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Go round')).toBeInTheDocument();
expect(within(badge).getByText('Route still passes')).toBeInTheDocument();
});
it('PCB-023: an entry to check again gets one badge for each change, each with the turning arrow', () => {
const entry = obstacleEntry({ status: 'review', changed: ['route', 'source'], decision: decisionOf('pass') });
render(<ObstacleStatusBadges entry={entry} />);
const badges = screen.getAllByTestId('paddle-check-status');
expect(badges.map((badge) => badge.textContent)).toEqual(['Check againRoute changed', 'Check againData changed']);
for (const badge of badges) expect(badge.querySelector('svg.lucide-rotate-ccw')).not.toBeNull();
// The earlier choice is the panel's to show, not the badge's.
expect(screen.queryByText('Paddle through')).not.toBeInTheDocument();
});
it('PCB-024: one change gives one badge', () => {
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'review', changed: ['source'] })} />);
const badges = screen.getAllByTestId('paddle-check-status');
expect(badges).toHaveLength(1);
expect(badges[0]?.textContent).toBe('Check againData changed');
});
it('PCB-025: an entry to check again without a recorded change still says so, with the turning arrow', () => {
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'review', changed: [] })} />);
const badge = screen.getByTestId('paddle-check-status');
expect(badge.textContent).toBe('Check again');
expect(badge.querySelector('svg.lucide-rotate-ccw')).not.toBeNull();
});
it('PCB-026: a decision about an object the data no longer holds says so, and kept it steps back', async () => {
const { rerender } = render(<ObstacleStatusBadges entry={earlierEntry()} />);
let badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Paddle through')).toBeInTheDocument();
expect(within(badge).getByText('No longer in the data')).toBeInTheDocument();
expect(badge.closest('.opacity-60')).toBeNull();
// Missing from the data is no proof the obstacle is gone.
expect(await tooltipOf(badge)).toBe(GONE_INFO);
rerender(<ObstacleStatusBadges entry={earlierEntry({ status: 'kept' })} />);
badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText('Paddle through')).toBeInTheDocument();
expect(within(badge).getByText('No longer in the data')).toBeInTheDocument();
expect(badge.closest('.opacity-60')).not.toBeNull();
});
it.each(EARLIER)(
'$id: an earlier decision now $status names the decision and why it stands apart',
({ status, decision, chosen, word }) => {
render(<ObstacleStatusBadges entry={earlierEntry({ status, decision: decisionOf(decision) })} />);
const badge = screen.getByTestId('paddle-check-status');
expect(within(badge).getByText(chosen)).toBeInTheDocument();
expect(within(badge).getByText(word)).toBeInTheDocument();
expect(badge.closest('.opacity-60')).toBeNull();
}
);
});
describe('ObstacleAccessBadge', () => {
it('PCB-029: a rule that neither forbids, makes private nor carries a condition shows nothing', () => {
const { container, rerender } = render(<ObstacleAccessBadge access={NO_RULE} />);
expect(container).toBeEmptyDOMElement();
rerender(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'yes', source: 'canoe' }} />);
expect(container).toBeEmptyDOMElement();
});
it('PCB-030: a ban for canoes names the canoe rule and colours its value as a warning', () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'no', source: 'canoe', forbidden: true }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Canoe')).not.toHaveClass('text-warning');
expect(within(badge).getByText('no').parentElement).toHaveClass('text-warning');
});
it('PCB-031: private ground under the general access rule is a warning too', () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'private', source: 'access', private: true }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Access')).toBeInTheDocument();
expect(within(badge).getByText('private').parentElement).toHaveClass('text-warning');
});
it('PCB-032: a value TREK has no words for is shown as written, its condition in the tooltip', async () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'permit', source: 'boat', condition: CONDITION }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Boat')).toBeInTheDocument();
expect(within(badge).getByText('permit').parentElement).toHaveClass('text-content');
expect(within(badge).getByText('permit').parentElement).not.toHaveClass('text-warning');
expect(await tooltipOf(badge)).toBe(`Condition: ${CONDITION}`);
});
it('PCB-033: a condition without a value of its own is shown as written under its own label', () => {
render(<ObstacleAccessBadge access={{ ...NO_RULE, condition: CONDITION }} />);
const badge = screen.getByTestId('paddle-check-access');
expect(within(badge).getByText('Condition')).toBeInTheDocument();
expect(within(badge).getByText(CONDITION)).toBeInTheDocument();
});
});
@@ -0,0 +1,209 @@
import type {
ObstacleDecision,
ObstacleEntryStatus,
PaddleCheck,
PaddleCheckData,
PaddleObstacleKind,
TourObstacleEntry,
} from '@trek/shared';
import { RotateCcw } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { TourDayRowPill } from '../TourDayRowFacts';
import { FigureBadge } from './FigureBadge';
import { PaddleGlyph } from './PaddleGlyph';
import { checkDataWord, formatCheckDate, paddleCheckFact } from './paddleCheckModel';
import { OBSTACLE_GLYPH } from './paddleGlyphPaths';
import { KNOWN_ACCESS_VALUES } from './paddleObstacleModel';
import { paddleKindCss } from './paddlePaint';
type Translate = (key: string, params?: Record<string, string | number>) => string;
const ACCESS_KEYS: readonly string[] = KNOWN_ACCESS_VALUES;
/** Why data that falls short of the route asks for a look of one's own, as the obstacle notice says it. */
const COVERAGE_INFO: Record<Exclude<PaddleCheckData, 'covered'>, (t: Translate, date: string) => string> = {
partial: (t, date) => t('tours.paddle.obstacles.partial', { date }),
none: (t) => t('tours.paddle.obstacles.none'),
limited: (t) => t('tours.paddle.obstacles.truncated'),
unavailable: (t) => t('tours.paddle.obstacles.error'),
};
/** How a status reads beside the decision it is about. */
interface StatusLook {
/** The status in words; null where the decision alone says it. */
word: (t: Translate, decision: ObstacleDecision | null) => string | null;
info?: (t: Translate) => string;
/** A decision kept about an object the data no longer holds steps back. */
dimmed?: boolean;
}
const GONE: StatusLook = {
word: (t) => t('tours.paddle.check.status.gone'),
info: (t) => t('tours.paddle.check.goneInfo'),
};
/** Every status but review, which gets a badge for each thing that changed. */
const STATUS_LOOK: Record<Exclude<ObstacleEntryStatus, 'review'>, StatusLook> = {
open: { word: (t) => t('tours.paddle.check.status.open') },
decided: { word: () => null },
// A choice whose plan is missing: no portage drawn yet, or the route still passes.
todo: {
word: (t, decision) =>
t(
decision === 'reroute' ? 'tours.paddle.check.status.reroutePending' : 'tours.paddle.check.status.portageMissing'
),
},
gone: GONE,
kept: { ...GONE, dimmed: true },
off_route: { word: (t) => t('tours.paddle.check.status.offRoute') },
unknown: { word: (t) => t('tours.paddle.check.status.unknown') },
};
/**
* The check bench of a paddle tour as one pill (PAD-09), in the badge the day
* plan gives a tour's facts: the first rule that applies gives its value, and
* its tooltip names the whole bench and that it is no clearance.
*/
export function PaddleCheckPill({ check }: { check: PaddleCheck }) {
const { t } = useTranslation();
const { icon, value, label } = paddleCheckFact(check, t);
return <TourDayRowPill icon={icon} value={value} label={label} testId="plan-tour-paddle-check" />;
}
/**
* How far the obstacle data of a bench reaches and how current it is: a badge
* for data that does not cover the route, the date OpenStreetMap is current
* to, and when TREK fetched it, which is never a check on site.
*/
export function PaddleCheckDataLine({ check }: { check: PaddleCheck }) {
const { t, locale } = useTranslation();
const word = checkDataWord(check.data, t);
const asOf = check.data_as_of ? formatCheckDate(check.data_as_of, locale) : null;
const coverage =
check.data === 'covered' ? null : COVERAGE_INFO[check.data](t, asOf ?? t('tours.paddle.obstacles.dateUnknown'));
return (
<div data-testid="paddle-check-data" className="flex flex-wrap items-center gap-1">
{word && coverage && (
<FigureBadge
testId="paddle-check-coverage"
label={t('tours.paddle.check.dataLabel')}
value={word}
info={coverage}
/>
)}
<FigureBadge
testId="paddle-check-as-of"
label={t('tours.paddle.check.sourceDate')}
value={asOf ?? t('tours.paddle.check.unknown')}
info={
asOf ? t('tours.paddle.obstacleCard.source', { date: asOf }) : t('tours.paddle.obstacleCard.sourceUndated')
}
/>
{check.read_at ? (
<FigureBadge
testId="paddle-check-read"
label={t('tours.paddle.check.readDate')}
value={formatCheckDate(check.read_at, locale)}
/>
) : null}
</div>
);
}
/**
* The symbol of a checklist row: the kind's glyph in a ring of its colour,
* the ring dashed for an object only near the route. The paddler's own
* portages and locks take the portage and lock kinds.
*/
export function ChecklistGlyph({
kind,
dashed = false,
size = 24,
}: {
kind: PaddleObstacleKind;
/** An object only near the route: how it stands to it is uncertain. */
dashed?: boolean;
size?: number;
}) {
const color = paddleKindCss(kind);
return (
<span
data-testid="paddle-check-glyph"
aria-hidden="true"
className={`grid flex-none place-items-center rounded-full border-2 bg-surface-card ${dashed ? 'border-dashed' : 'border-solid'}`}
style={{ width: size, height: size, color, borderColor: color }}
>
<PaddleGlyph kind={OBSTACLE_GLYPH[kind]} size={Math.round(size * 0.55)} strokeWidth={2.3} />
</span>
);
}
/** An entry to check again: one badge for each thing that changed since the decision. */
function ReviewBadges({ changed }: { changed: TourObstacleEntry['changed'] }) {
const { t } = useTranslation();
const review = t('tours.paddle.check.status.review');
const icon = <RotateCcw size={10} strokeWidth={2.4} aria-hidden="true" className="flex-none" />;
if (changed.length === 0) return <FigureBadge testId="paddle-check-status" value={review} icon={icon} />;
return (
<>
{changed.map((reason) => (
<FigureBadge
key={reason}
testId="paddle-check-status"
label={review}
value={t(`tours.paddle.check.changed.${reason}`)}
icon={icon}
/>
))}
</>
);
}
/**
* Where a checklist entry stands, as its row and its decision panel show it:
* the decision with what still stands in its way, a badge for each change an
* entry to check again has seen, and a decision kept about an object the data
* no longer holds dimmed. The badges come without a box of their own, so they
* flow on in the line they are put into.
*/
export function ObstacleStatusBadges({ entry }: { entry: TourObstacleEntry }) {
const { t } = useTranslation();
if (entry.status === 'review') return <ReviewBadges changed={entry.changed} />;
const look = STATUS_LOOK[entry.status];
const decision = entry.decision?.decision ?? null;
const chosen = decision ? t(`tours.paddle.check.decision.${decision}`) : undefined;
const word = look.word(t, decision);
const value = word ?? chosen;
if (!value) return null;
const badge = (
<FigureBadge testId="paddle-check-status" label={word ? chosen : undefined} value={value} info={look.info?.(t)} />
);
return look.dimmed ? <span className="inline-flex opacity-60">{badge}</span> : badge;
}
/** A rule's value in the user's words where TREK knows it, as the tags write it where it does not. */
function accessValueOf(value: string, t: Translate): string {
return ACCESS_KEYS.includes(value) ? t(`tours.paddle.obstacleCard.accessValue.${value}`) : value;
}
/**
* The paddling rule an obstacle's tags give, on its row only when it forbids,
* makes the ground private or carries a condition. A rule that forbids or
* makes it private takes the warning tone, as on the map card; a condition
* without a value of its own is shown as written.
*/
export function ObstacleAccessBadge({ access }: { access: TourObstacleEntry['access'] }) {
const { t } = useTranslation();
if (!access.forbidden && !access.private && !access.condition) return null;
const condition = t('tours.paddle.obstacleCard.condition');
const { value } = access;
return (
<FigureBadge
testId="paddle-check-access"
label={value === null ? condition : t(`tours.paddle.obstacleCard.access.${access.source ?? 'access'}`)}
value={value === null ? (access.condition ?? '') : accessValueOf(value, t)}
info={access.condition ? `${condition}: ${access.condition}` : undefined}
tone={access.forbidden || access.private ? 'warning' : 'neutral'}
/>
);
}
@@ -0,0 +1,445 @@
// FE-PADDLE-CHECKCARD-001 to FE-PADDLE-CHECKCARD-017: the checklist card of a paddle tour (PAD-06) in the planner
// rail, its state notices, its rows in route order and the fold of earlier decisions.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PaddleAccess, PaddleCheck, TourObstacleEntry } from '@trek/shared';
import { buildSettings } from '../../../../tests/helpers/factories';
import {
earlierEntry,
obstacleCheck,
obstacleEntry,
ownPortage,
paddleBench,
} from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { PaddleCheckCard } from './PaddleCheckCard';
import { formatCheckDate } from './paddleCheckModel';
import type { PaddleChecklist } from './usePaddleChecklist';
// The badges, the entry dialog and the data notice have suites of their own; here they show what the card hands them.
vi.mock('./PaddleCheckBadges', () => ({
ChecklistGlyph: ({ kind, dashed = false }: { kind: string; dashed?: boolean; size?: number }) => (
<span data-testid="checklist-glyph" data-kind={kind} data-dashed={String(dashed)} />
),
ObstacleStatusBadges: ({ entry }: { entry: TourObstacleEntry }) => <span>status {entry.status}</span>,
ObstacleAccessBadge: ({ access }: { access: PaddleAccess }) =>
access.forbidden || access.private ? <span>access {access.value}</span> : null,
PaddleCheckPill: ({ check }: { check: PaddleCheck }) => (
<span data-testid="paddle-check-pill">{check.open} open</span>
),
PaddleCheckDataLine: ({ check }: { check: PaddleCheck }) => <span data-testid="paddle-check-data">{check.data}</span>,
}));
vi.mock('./ObstacleEntryDialog', () => ({
ObstacleEntryDialog: ({ checklist }: { checklist: PaddleChecklist }) => (
<div role="dialog" aria-label="Obstacle entry">
{checklist.dialogKey}
</div>
),
}));
vi.mock('./PaddleObstacleNotice', () => ({ PaddleObstacleNotice: () => <p>What TREK knows about the water</p> }));
/** The line the data notice stub shows. */
const OBSTACLE_NOTICE = 'What TREK knows about the water';
/** The words as shared/src/i18n/en/tours.ts has them. */
const SAYS = {
title: 'Checklist',
saveFirst: 'Save the tour to check it.',
loading: 'Loading the obstacles along the route …',
offlineEmpty: 'The checklist loads once you are online.',
error: 'The obstacle data could not be loaded. Check the water yourself.',
unsaved: 'Unsaved changes. The list shows the saved route.',
none: 'No obstacle data for this area. Check weirs, locks and portages here yourself.',
dateUnknown: 'an unknown date',
notClearance: 'A checklist is no safety or legal clearance.',
earlier: 'Earlier decisions',
own: 'Own',
ownInfo: 'Your own plan, not a fact from a data source.',
near: 'Near the route',
supLock: 'SUP: check a portage',
privateWay: 'Private way',
carry: 'Carrying',
bypassed: 'Carried round, the portage counts',
};
const staleLine = (time: string): string => `As of ${time}. Updates once online.`;
const coveredEmptyLine = (date: string): string =>
`No known obstacles along the route (OpenStreetMap as of ${date}). Check the water yourself all the same.`;
/** A date as the card writes it, with the spaces the DOM queries read. */
const dateOf = (value: string): string => formatCheckDate(value, 'en').replace(/\s/g, ' ');
/** The weir of the fixtures: on the route at 1.2 km, open. */
const WEIR = obstacleEntry();
/** A weir further on that OpenStreetMap closes to canoes. */
const BANNED_WEIR = obstacleEntry({
key: 'way/3',
refs: ['way/3'],
name: 'Wehr Kallmünz',
along_m: 2500,
access: { value: 'no', source: 'canoe', condition: null, forbidden: true, private: false },
});
/** An unnamed lock beside the route, decided already, where SUP paddlers are advised to carry round. */
const NEAR_LOCK = obstacleEntry({
key: 'lock:node/2',
refs: ['node/2', 'node/3'],
kind: 'lock',
name: null,
relation: 'near',
along_m: 3400,
distance_m: 40,
advice: 'sup_lock_portage',
status: 'decided',
decision: { decision: 'portage', note: null, decided_at: '2026-10-10T09:00:00.000Z', decided_by: 1 },
});
/** The paddler's own portage round the first weir. */
const OWN_PORTAGE = ownPortage({ from_m: 1100, to_m: 1250 });
/** A lock the paddler marked and locks through on a SUP, so the advice to check a portage stands. */
const ADVISED_LOCK = ownPortage({
kind: 'lock',
index: 4,
from_m: 5000,
to_m: 5000,
covers: [],
advice: 'sup_lock_portage',
});
/** A lock the paddler marked that a portage carries round; the server gives it no advice. */
const BYPASSED_LOCK = ownPortage({ kind: 'lock', index: 5, from_m: 5600, to_m: 5600, covers: [], bypassed: true });
/** All of it out of route order, as the server may list it. */
const ROUTE = obstacleCheck({ entries: [NEAR_LOCK, WEIR, BANNED_WEIR], own: [ADVISED_LOCK, OWN_PORTAGE] });
/** The weir, and an earlier decision about a sill the route no longer meets. */
const GONE = earlierEntry({ status: 'off_route' });
const GONE_NAME = 'Alte Sohlschwelle';
const WITH_EARLIER = obstacleCheck({ entries: [WEIR, GONE] });
/** The weir, and a decision about a sill the complete data no longer holds: it counts, so it stays in view. */
const VANISHED = earlierEntry({ key: 'node/8', refs: ['node/8'], name: 'Verschwundenes Wehr' });
/** What usePaddleChecklist hands the card: a ready list matching the saved route, nothing selected, every action a spy. */
function checklistOf(overrides: Partial<PaddleChecklist> = {}): PaddleChecklist {
return {
status: 'ready',
tour: null,
check: obstacleCheck(),
summary: paddleBench(),
stale: false,
dirty: false,
canDecide: true,
canPlanPortage: true,
savingKey: null,
selectedKey: null,
dialogKey: null,
entryForRef: vi.fn(() => null),
select: vi.fn(),
selectOwn: vi.fn(),
closeDialog: vi.fn(),
decide: vi.fn(async () => true),
setNote: vi.fn(async () => true),
clear: vi.fn(async () => true),
keep: vi.fn(async () => true),
nextOf: vi.fn(() => null),
next: vi.fn(),
planPortage: vi.fn(),
memberName: vi.fn(() => null),
...overrides,
};
}
const renderCard = (checklist: PaddleChecklist) => render(<PaddleCheckCard checklist={checklist} />);
/** The rows along the route, in the order the card lists them. */
function rowButtons(): HTMLElement[] {
return within(screen.getByTestId('paddle-check-rows')).getAllByRole('button');
}
/** The row along the route whose name starts as given. */
function rowNamed(name: RegExp): HTMLElement {
return within(screen.getByTestId('paddle-check-rows')).getByRole('button', { name });
}
/** The element a badge's tooltip hangs on. */
function parentOf(element: HTMLElement): HTMLElement {
const parent = element.parentElement;
if (!parent) throw new Error('no parent around the badge');
return parent;
}
beforeEach(() => {
vi.clearAllMocks();
// No distance unit chosen: the card reads metric, in English.
seedStore(useSettingsStore, { settings: buildSettings() });
});
describe('PaddleCheckCard', () => {
it('FE-PADDLE-CHECKCARD-001: renders nothing while the checklist is off', () => {
const { container } = renderCard(checklistOf({ status: 'off', check: null, summary: null }));
expect(container).toBeEmptyDOMElement();
});
it('FE-PADDLE-CHECKCARD-002: a new paddle tour asks to be saved first and says what is known about the water', () => {
renderCard(checklistOf({ status: 'new', check: null, summary: null, dirty: true }));
const card = screen.getByRole('region', { name: SAYS.title });
const saveFirst = within(card).getByText(SAYS.saveFirst);
const notice = within(card).getByText(OBSTACLE_NOTICE);
// The request to save comes first, the data notice below it; a new draft is unsaved by nature and says no more.
expect(saveFirst.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(within(card).queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(within(card).queryByTestId('paddle-check-pill')).not.toBeInTheDocument();
expect(within(card).queryByTestId('paddle-check-data')).not.toBeInTheDocument();
expect(within(card).queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-003: while the list loads it says so as a status with a spinner, the bench already in the head', () => {
renderCard(checklistOf({ status: 'loading', check: null, dirty: true }));
const status = screen.getByRole('status');
expect(status).toHaveTextContent(SAYS.loading);
expect(status.querySelector('svg.animate-spin')).not.toBeNull();
// Loading speaks before unsaved changes.
expect(screen.queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(screen.queryByText(OBSTACLE_NOTICE)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-pill')).toHaveTextContent('1 open');
expect(screen.getByTestId('paddle-check-data')).toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-004: offline without a cached list it says the list loads once online', () => {
renderCard(checklistOf({ status: 'offline-empty', check: null }));
expect(screen.getByText(SAYS.offlineEmpty)).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-005: a list that could not be read says the obstacle data could not be loaded', () => {
renderCard(checklistOf({ status: 'error', check: null }));
expect(screen.getByText(SAYS.error)).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-006: unsaved route changes speak before the cache and dim the rows without hiding them', () => {
renderCard(checklistOf({ check: ROUTE, dirty: true, stale: true }));
expect(screen.getByText(SAYS.unsaved)).toBeInTheDocument();
expect(screen.queryByText(/Updates once online/)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-list')).toHaveClass('opacity-60');
expect(rowButtons()).toHaveLength(5);
});
it('FE-PADDLE-CHECKCARD-007: a list from the cache says when it was worked out and dims the rows', () => {
renderCard(checklistOf({ check: ROUTE, stale: true }));
expect(screen.getByText(staleLine(dateOf(ROUTE.evaluated_at)))).toBeInTheDocument();
expect(screen.queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-list')).toHaveClass('opacity-60');
expect(rowButtons()).toHaveLength(5);
});
it('FE-PADDLE-CHECKCARD-008: lists obstacles and own marks in route order, each with its place and where it stands', () => {
renderCard(checklistOf({ check: ROUTE }));
const card = screen.getByRole('region', { name: SAYS.title });
expect(within(card).getByTestId('paddle-check-pill')).toHaveTextContent('1 open');
expect(within(card).getByTestId('paddle-check-data')).toHaveTextContent('covered');
// A ready list that matches the saved route has nothing to say above it and is not dimmed.
expect(within(card).queryByRole('status')).not.toBeInTheDocument();
expect(within(card).queryByText(SAYS.unsaved)).not.toBeInTheDocument();
expect(screen.getByTestId('paddle-check-list')).not.toHaveClass('opacity-60');
const rows = rowButtons();
expect(rows.map((row) => row.textContent)).toEqual([
expect.stringMatching(/^Portage1\.1 km/),
expect.stringMatching(/^Wehr Regendorf1\.2 km/),
expect.stringMatching(/^Wehr Kallmünz2\.5 km/),
expect.stringMatching(/^Lock3\.4 km/),
expect.stringMatching(/^Lock5 km/),
]);
const [, weir, banned, lock] = rows;
expect(within(weir).getByText('status open')).toBeInTheDocument();
expect(within(weir).getByTestId('checklist-glyph')).toHaveAttribute('data-dashed', 'false');
expect(within(weir).queryByText(SAYS.near)).not.toBeInTheDocument();
expect(within(weir).queryByText(/^access/)).not.toBeInTheDocument();
expect(within(banned).getByText('access no')).toBeInTheDocument();
// An unnamed lock goes by its kind: near the route on a dashed ring, its decision and the SUP advice.
expect(within(lock).getByTestId('checklist-glyph')).toHaveAttribute('data-kind', 'lock');
expect(within(lock).getByTestId('checklist-glyph')).toHaveAttribute('data-dashed', 'true');
expect(within(lock).getByText(SAYS.near)).toBeInTheDocument();
expect(within(lock).getByText('status decided')).toBeInTheDocument();
expect(within(lock).getByText(SAYS.supLock)).toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-009: an own portage reads as the own plan, how far it carries, what it carries round', async () => {
renderCard(
checklistOf({ check: obstacleCheck({ own: [ownPortage({ from_m: 1100, to_m: 1250, conflict: 'private' })] }) })
);
const row = rowNamed(/^Portage/);
expect(
within(row)
.getAllByTestId('checklist-glyph')
.map((glyph) => glyph.dataset.kind)
).toEqual(['portage', 'weir']);
expect(within(row).getByText(SAYS.carry).nextElementSibling).toHaveTextContent('150 m');
expect(within(row).getByRole('img', { name: 'Weir' })).toBeInTheDocument();
expect(within(row).getByText(SAYS.privateWay)).toBeInTheDocument();
fireEvent.mouseEnter(parentOf(within(row).getByText(SAYS.own)));
expect(await screen.findByRole('tooltip')).toHaveTextContent(SAYS.ownInfo);
});
it('FE-PADDLE-CHECKCARD-010: a forbidden carrying way reads as its access rule, a lock carried round is dimmed', () => {
const forbidden = ownPortage({ from_m: 2000, to_m: 2200, conflict: 'forbidden' });
renderCard(checklistOf({ check: obstacleCheck({ entries: [], own: [BYPASSED_LOCK, ADVISED_LOCK, forbidden] }) }));
const [portage, advised, bypassed] = rowButtons();
expect(portage).toHaveTextContent('access no');
expect(portage).not.toHaveTextContent(SAYS.privateWay);
// A covered obstacle the checklist does not hold gets no chip.
expect(within(portage).queryByRole('img')).not.toBeInTheDocument();
expect(portage).not.toHaveClass('opacity-60');
// A lock passed on a SUP carries the advice; one a portage carries round is dimmed and says why.
expect(within(advised).getByTestId('checklist-glyph')).toHaveAttribute('data-kind', 'lock');
expect(advised).toHaveTextContent(SAYS.own);
expect(advised).toHaveTextContent(SAYS.supLock);
expect(advised).not.toHaveTextContent(SAYS.carry);
expect(advised).not.toHaveClass('opacity-60');
expect(bypassed).toHaveClass('opacity-60');
expect(bypassed).toHaveTextContent(SAYS.own);
expect(bypassed).toHaveTextContent(SAYS.bypassed);
expect(bypassed).not.toHaveTextContent(SAYS.supLock);
});
it('FE-PADDLE-CHECKCARD-011: a row is one button that selects its obstacle or its own mark', () => {
const select = vi.fn<PaddleChecklist['select']>();
const selectOwn = vi.fn<PaddleChecklist['selectOwn']>();
renderCard(checklistOf({ check: ROUTE, select, selectOwn }));
for (const row of rowButtons()) expect(row).toHaveAttribute('type', 'button');
// Nothing to press inside a row.
expect(within(rowNamed(/^Wehr Kallmünz/)).queryByRole('button')).not.toBeInTheDocument();
fireEvent.click(rowNamed(/^Wehr Kallmünz/));
expect(select).toHaveBeenCalledWith(BANNED_WEIR);
fireEvent.click(rowNamed(/^Portage/));
expect(selectOwn).toHaveBeenCalledWith(OWN_PORTAGE);
expect(select).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-CHECKCARD-012: the selected entry is the current row, scrolled into view, and the mark follows the selection', () => {
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView);
const { rerender } = renderCard(checklistOf({ check: ROUTE, selectedKey: WEIR.key }));
const weir = rowNamed(/^Wehr Regendorf/);
expect(weir).toHaveAttribute('aria-current', 'true');
expect(weir).toHaveClass('ring-1', 'ring-inset', 'ring-accent', 'bg-surface-hover');
expect(rowButtons().filter((row) => row.hasAttribute('aria-current'))).toEqual([weir]);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
expect(scrollIntoView.mock.contexts[0]).toBe(weir);
rerender(<PaddleCheckCard checklist={checklistOf({ check: ROUTE, selectedKey: BANNED_WEIR.key })} />);
const banned = rowNamed(/^Wehr Kallmünz/);
expect(banned).toHaveAttribute('aria-current', 'true');
expect(weir).not.toHaveAttribute('aria-current');
expect(weir).not.toHaveClass('ring-accent');
expect(scrollIntoView).toHaveBeenCalledTimes(2);
expect(scrollIntoView.mock.contexts[1]).toBe(banned);
});
it('FE-PADDLE-CHECKCARD-013: earlier decisions wait folded under their count, apart from the route, and open on request', () => {
const select = vi.fn<PaddleChecklist['select']>();
renderCard(checklistOf({ check: WITH_EARLIER, select }));
const fold = screen.getByRole('button', { name: SAYS.earlier });
expect(fold).toHaveAttribute('aria-expanded', 'false');
expect(within(screen.getByTestId('paddle-check-earlier')).getByText('1')).toBeInTheDocument();
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
expect(rowButtons()).toHaveLength(1);
fireEvent.click(fold);
expect(fold).toHaveAttribute('aria-expanded', 'true');
const earlier = screen.getByRole('button', { name: /^Alte Sohlschwelle/ });
// No longer on the route, so no place along it.
expect(earlier).not.toHaveTextContent('km');
expect(earlier).toHaveTextContent('status off_route');
fireEvent.click(earlier);
expect(select).toHaveBeenCalledWith(GONE);
});
it('FE-PADDLE-CHECKCARD-014: a selected earlier decision opens the fold by itself and comes into view, until folded again', () => {
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView);
const { rerender } = renderCard(checklistOf({ check: WITH_EARLIER }));
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER, selectedKey: GONE.key })} />);
const fold = screen.getByRole('button', { name: SAYS.earlier });
expect(fold).toHaveAttribute('aria-expanded', 'true');
const row = screen.getByRole('button', { name: /^Alte Sohlschwelle/ });
expect(row).toHaveAttribute('aria-current', 'true');
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
expect(scrollIntoView.mock.contexts).toContain(row);
fireEvent.click(fold);
expect(fold).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-018: a decision about an object the data no longer holds stays among the rows, after the route', () => {
renderCard(checklistOf({ check: obstacleCheck({ entries: [VANISHED, WEIR] }) }));
const rows = rowButtons();
expect(rows).toHaveLength(2);
expect(rows[1]).toHaveTextContent('Verschwundenes Wehr');
expect(screen.queryByRole('button', { name: SAYS.earlier })).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-015: without rows it says nothing is known on covered data, and that there is none where none is', () => {
const empty = obstacleCheck({ entries: [], own: [] });
const bench = paddleBench({ entries: 0, open: 0 });
const { rerender } = renderCard(checklistOf({ check: empty, summary: bench }));
expect(screen.getByText(coveredEmptyLine(dateOf(bench.data_as_of ?? '')))).toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-rows')).not.toBeInTheDocument();
rerender(<PaddleCheckCard checklist={checklistOf({ check: empty, summary: { ...bench, data_as_of: null } })} />);
expect(screen.getByText(coveredEmptyLine(SAYS.dateUnknown))).toBeInTheDocument();
rerender(
<PaddleCheckCard
checklist={checklistOf({ check: empty, summary: { ...bench, data: 'none', data_as_of: null } })}
/>
);
expect(screen.getByText(SAYS.none)).toBeInTheDocument();
expect(screen.queryByText(/No known obstacles/)).not.toBeInTheDocument();
// Partial data says so in its data line; the card adds no sentence of its own.
rerender(<PaddleCheckCard checklist={checklistOf({ check: empty, summary: { ...bench, data: 'partial' } })} />);
expect(screen.queryByText(SAYS.none)).not.toBeInTheDocument();
expect(screen.queryByText(/No known obstacles/)).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-016: mounts the entry dialog only while an entry the map cannot show is open in it', () => {
const { rerender } = renderCard(checklistOf({ check: WITH_EARLIER }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER, dialogKey: GONE.key })} />);
expect(screen.getByRole('dialog', { name: 'Obstacle entry' })).toHaveTextContent(GONE.key);
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER })} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-PADDLE-CHECKCARD-017: closes on the line that a checklist is no clearance, before and after the tour is saved', () => {
const { rerender } = renderCard(checklistOf({ status: 'new', check: null, summary: null }));
expect(screen.getByTestId('paddle-check-card').lastElementChild).toHaveTextContent(SAYS.notClearance);
rerender(<PaddleCheckCard checklist={checklistOf({ check: ROUTE })} />);
expect(screen.getByTestId('paddle-check-card').lastElementChild).toHaveTextContent(SAYS.notClearance);
});
});
@@ -0,0 +1,387 @@
import type {
PaddleAccess,
PaddleCheck,
PaddleObstacleKind,
TourObstacleCheck,
TourObstacleEntry,
TourOwnEntry,
} from '@trek/shared';
import { AlertTriangle, CloudOff, LifeBuoy, ListChecks, Loader2, PencilLine, Save, WifiOff } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { FoldButton, RailCard, TourNotice } from '../tourParts';
import { FigureBadge } from './FigureBadge';
import { ObstacleEntryDialog } from './ObstacleEntryDialog';
import {
ChecklistGlyph,
ObstacleAccessBadge,
ObstacleStatusBadges,
PaddleCheckDataLine,
PaddleCheckPill,
} from './PaddleCheckBadges';
import { checklistRows, formatCheckDate, type ChecklistRow } from './paddleCheckModel';
import { PaddleObstacleNotice } from './PaddleObstacleNotice';
import type { PaddleChecklist, PaddleChecklistStatus } from './usePaddleChecklist';
type Translate = ReturnType<typeof useTranslation>['t'];
/** What the card says above the rows: the tour's own state, unsaved changes, or an answer from the cache. */
type CheckNotice = Exclude<PaddleChecklistStatus, 'off' | 'ready'> | 'dirty' | 'stale';
const ICON = { size: 13, strokeWidth: 2 } as const;
/** Each state's sentence and icon; while the list loads it is a status with a spinner. */
const NOTICES: Record<CheckNotice, { textKey: string; icon: ReactNode; role?: 'status' }> = {
new: { textKey: 'tours.paddle.check.saveFirst', icon: <Save {...ICON} /> },
loading: {
textKey: 'tours.paddle.obstacles.loading',
icon: <Loader2 {...ICON} className="animate-spin" />,
role: 'status',
},
'offline-empty': { textKey: 'tours.paddle.check.offlineEmpty', icon: <WifiOff {...ICON} /> },
error: { textKey: 'tours.paddle.obstacles.error', icon: <AlertTriangle {...ICON} /> },
dirty: { textKey: 'tours.paddle.check.unsaved', icon: <PencilLine {...ICON} /> },
stale: { textKey: 'tours.paddle.check.stale', icon: <CloudOff {...ICON} /> },
};
/** A carrying way the data forbids (`access=no` or `portage:access=no`), as the access rule it runs into. */
const FORBIDDEN_WAY: PaddleAccess = { value: 'no', source: 'access', condition: null, forbidden: true, private: false };
const ROW =
'flex w-full items-start gap-2 rounded-[10px] px-1.5 py-1.5 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
const ROW_SELECTED = 'bg-surface-hover ring-1 ring-inset ring-accent';
/** The one notice that applies: the tour's state first, then unsaved route changes, then a list from the cache. */
function noticeOf({ status, dirty, stale }: Pick<PaddleChecklist, 'status' | 'dirty' | 'stale'>): CheckNotice | null {
if (status === 'off') return null;
if (status !== 'ready') return status;
if (dirty) return 'dirty';
return stale ? 'stale' : null;
}
function kindLabel(kind: PaddleObstacleKind, t: Translate): string {
return t(`tours.paddle.obstacle.kind.${kind}`);
}
/** Metres along the route in the user's distance unit. */
function useDistanceLabel(): (metres: number) => string {
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
return (metres) => formatDistance(metres / 1000, unit);
}
function rowKey(row: ChecklistRow, index: number): string {
return row.type === 'obstacle' ? row.entry.key : `own:${row.own.kind}:${row.own.from_m}:${index}`;
}
/** The sentence the checklist's state calls for. */
function CheckNoticeLine({ notice, evaluatedAt }: { notice: CheckNotice; evaluatedAt: string | null }) {
const { t, locale } = useTranslation();
const { textKey, icon, role } = NOTICES[notice];
const time = evaluatedAt ? formatCheckDate(evaluatedAt, locale) : '';
return (
<TourNotice role={role} icon={icon}>
{t(textKey, { time })}
</TourNotice>
);
}
/** A row of the checklist: one full width button with the glyph, the title and a line of badges that wraps between them. */
function CheckRowButton({
selected = false,
dimmed = false,
onClick,
glyph,
title,
children,
}: {
selected?: boolean;
dimmed?: boolean;
onClick: () => void;
glyph: ReactNode;
title: string;
children: ReactNode;
}) {
const ref = useRef<HTMLButtonElement>(null);
// An entry picked on the map comes into view in the rail.
useEffect(() => {
if (selected) ref.current?.scrollIntoView?.({ block: 'nearest' });
}, [selected]);
return (
<button
ref={ref}
type="button"
onClick={onClick}
aria-current={selected ? 'true' : undefined}
className={`${ROW} ${selected ? ROW_SELECTED : 'hover:bg-surface-hover'} ${dimmed ? 'opacity-60' : ''}`}
>
<span className="flex-none">{glyph}</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-semibold text-content" style={fs(12.5, 'body')} title={title}>
{title}
</span>
<span className="flex flex-wrap items-center gap-1">{children}</span>
</span>
</button>
);
}
/** The SUP advice at a lock: check a portage rather than lock through. */
function SupLockBadge() {
const { t } = useTranslation();
return (
<SoftPill icon={<LifeBuoy size={10} strokeWidth={2.2} className="flex-none" />}>
{t('tours.paddle.check.supLock')}
</SoftPill>
);
}
/** Marks a row as the paddler's own plan, never a fact from a data source. */
function OwnBadge() {
const { t } = useTranslation();
return (
<Tooltip label={t('tours.paddle.check.ownInfo')}>
<span className="inline-flex flex-none">
<SoftPill>{t('tours.paddle.check.own')}</SoftPill>
</span>
</Tooltip>
);
}
/** The obstacles an own portage carries round, each a small glyph its tooltip names. */
function CoveredChips({ keys, entries }: { keys: readonly string[]; entries: readonly TourObstacleEntry[] }) {
const { t } = useTranslation();
const covered = keys
.map((key) => entries.find((entry) => entry.key === key))
.filter((entry): entry is TourObstacleEntry => entry !== undefined);
return (
<>
{covered.map((entry) => {
const label = kindLabel(entry.kind, t);
return (
<Tooltip key={entry.key} label={label}>
<span role="img" aria-label={label} className="inline-flex flex-none">
<ChecklistGlyph kind={entry.kind} size={18} />
</span>
</Tooltip>
);
})}
</>
);
}
/** A carrying way the data records as private or forbidden, in the warning tone the obstacle card gives rules. */
function ConflictBadge({ conflict }: { conflict: NonNullable<TourOwnEntry['conflict']> }) {
const { t } = useTranslation();
if (conflict === 'forbidden') return <ObstacleAccessBadge access={FORBIDDEN_WAY} />;
return <SoftPill tone="warning">{t('tours.paddle.obstacleCard.privateWay')}</SoftPill>;
}
/** One obstacle: what it is, where on the route, and where its decision stands. */
function PaddleCheckRow({
entry,
selected,
onSelect,
}: {
entry: TourObstacleEntry;
selected: boolean;
onSelect: (entry: TourObstacleEntry) => void;
}) {
const { t } = useTranslation();
const distance = useDistanceLabel();
const near = entry.relation === 'near';
return (
<CheckRowButton
selected={selected}
onClick={() => onSelect(entry)}
glyph={<ChecklistGlyph kind={entry.kind} dashed={near} />}
title={entry.name ?? kindLabel(entry.kind, t)}
>
{entry.along_m !== null && <SoftPill className="tabular-nums">{distance(entry.along_m)}</SoftPill>}
{near && <SoftPill>{t('tours.paddle.obstacleCard.near')}</SoftPill>}
<ObstacleStatusBadges entry={entry} />
<ObstacleAccessBadge access={entry.access} />
{entry.advice !== null && <SupLockBadge />}
</CheckRowButton>
);
}
/**
* The paddler's own portage or lock among the obstacles. A portage says how
* far it carries and what it carries round; a lock the portage carries round
* is dimmed and says so.
*/
function OwnCheckRow({
own,
entries,
onSelect,
}: {
own: TourOwnEntry;
entries: readonly TourObstacleEntry[];
onSelect: (own: TourOwnEntry) => void;
}) {
const { t } = useTranslation();
const distance = useDistanceLabel();
const portage = own.kind === 'portage';
const bypassed = !portage && own.bypassed;
return (
<CheckRowButton
dimmed={bypassed}
onClick={() => onSelect(own)}
glyph={<ChecklistGlyph kind={own.kind} />}
title={kindLabel(own.kind, t)}
>
<SoftPill className="tabular-nums">{distance(own.from_m)}</SoftPill>
<OwnBadge />
{portage && <FigureBadge label={t('tours.paddle.time.carry')} value={distance(own.to_m - own.from_m)} />}
{portage && <CoveredChips keys={own.covers} entries={entries} />}
{own.conflict && <ConflictBadge conflict={own.conflict} />}
{bypassed && <SoftPill>{t('tours.paddle.lockBypassed')}</SoftPill>}
{own.advice !== null && <SupLockBadge />}
</CheckRowButton>
);
}
/** One row along the route: an obstacle picks its entry, an own mark its place on the map. */
function RouteRow({
row,
checklist,
entries,
}: {
row: ChecklistRow;
checklist: PaddleChecklist;
entries: readonly TourObstacleEntry[];
}) {
if (row.type === 'own') return <OwnCheckRow own={row.own} entries={entries} onSelect={checklist.selectOwn} />;
return (
<PaddleCheckRow entry={row.entry} selected={row.entry.key === checklist.selectedKey} onSelect={checklist.select} />
);
}
/**
* The decisions the route or the data no longer carries, folded away under
* their count. A selection among them opens the fold, until the paddler folds
* it again for that selection.
*/
function EarlierDecisions({
entries,
selectedKey,
onSelect,
}: {
entries: readonly TourObstacleEntry[];
selectedKey: string | null;
onSelect: (entry: TourObstacleEntry) => void;
}) {
const { t } = useTranslation();
const id = useId();
const [choice, setChoice] = useState<{ open: boolean; key: string | null }>({ open: false, key: null });
const holdsSelected = entries.some((entry) => entry.key === selectedKey);
const expanded = (holdsSelected && choice.key !== selectedKey) || choice.open;
const label = t('tours.paddle.check.earlier');
return (
<div data-testid="paddle-check-earlier" className="border-t border-edge-faint pt-1.5">
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-semibold text-content-secondary" style={fs(11.5, 'body')}>
{label}
</span>
<SoftPill className="tabular-nums">{entries.length}</SoftPill>
<FoldButton
expanded={expanded}
controls={id}
label={label}
onToggle={() => setChoice({ open: !expanded, key: selectedKey })}
/>
</div>
<ul id={id} hidden={!expanded} className="-mx-1.5 mt-1 list-none p-0">
{expanded &&
entries.map((entry) => (
<li key={entry.key}>
<PaddleCheckRow entry={entry} selected={entry.key === selectedKey} onSelect={onSelect} />
</li>
))}
</ul>
</div>
);
}
/** A route without rows: nothing known where the data covers it, no data where there is none; else the data line speaks. */
function EmptyLine({ summary }: { summary: PaddleCheck | null }) {
const { t, locale } = useTranslation();
let text: string | null = null;
if (summary?.data === 'none') text = t('tours.paddle.obstacles.none');
else if (summary?.data === 'covered') {
const date = summary.data_as_of
? formatCheckDate(summary.data_as_of, locale)
: t('tours.paddle.obstacles.dateUnknown');
text = t('tours.paddle.obstacles.coveredEmpty', { date });
}
if (!text) return null;
return (
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{text}
</p>
);
}
/** The rows along the route, then the earlier decisions; dimmed, never hidden, while they may not match the route. */
function CheckList({ checklist, check }: { checklist: PaddleChecklist; check: TourObstacleCheck }) {
const { rows, earlier } = useMemo(() => checklistRows(check), [check]);
// Dimmed while it may not match the route, and while the list it came from reads again.
const dimmed = checklist.dirty || checklist.stale || checklist.status === 'loading';
return (
<div data-testid="paddle-check-list" className={`flex flex-col gap-1.5 ${dimmed ? 'opacity-60' : ''}`}>
{rows.length > 0 ? (
<ul data-testid="paddle-check-rows" className="-mx-1.5 flex list-none flex-col gap-0.5 p-0">
{rows.map((row, index) => (
<li key={rowKey(row, index)}>
<RouteRow row={row} checklist={checklist} entries={check.entries} />
</li>
))}
</ul>
) : (
<EmptyLine summary={checklist.summary} />
)}
{earlier.length > 0 && (
<EarlierDecisions entries={earlier} selectedKey={checklist.selectedKey} onSelect={checklist.select} />
)}
</div>
);
}
/**
* PAD-06 in the planner rail: the obstacle checklist of the open paddle tour.
* The obstacles and the paddler's own portages and locks stand in route
* order, each a row that picks it on the map. One line says what state the
* list is in; while the route has unsaved changes or the list comes from the
* cache, the rows stay readable but dimmed. The card closes on the line that
* a checklist is no safety or legal clearance, and opens an entry the map
* cannot show in a dialog of its own.
*/
export function PaddleCheckCard({ checklist }: { checklist: PaddleChecklist }) {
const { t } = useTranslation();
if (checklist.status === 'off') return null;
const notice = noticeOf(checklist);
const { summary, check } = checklist;
return (
<RailCard
icon={<ListChecks size={14} strokeWidth={2} />}
title={t('tours.paddle.check.title')}
aside={summary ? <PaddleCheckPill check={summary} /> : null}
>
<div data-testid="paddle-check-card" className="flex flex-col gap-2.5">
{notice && <CheckNoticeLine notice={notice} evaluatedAt={check?.evaluated_at ?? null} />}
{notice === 'new' && <PaddleObstacleNotice />}
{summary && <PaddleCheckDataLine check={summary} />}
{check && <CheckList checklist={checklist} check={check} />}
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.check.notClearance')}
</p>
</div>
{checklist.dialogKey !== null && <ObstacleEntryDialog checklist={checklist} />}
</RailCard>
);
}
@@ -0,0 +1,75 @@
// PCS-001 to PCS-004: the checklist section in a paddle tour's detail (PAD-09): the bench and how current its data
// is in one box, then the safety note, which stays when the tour has no bench yet.
import { describe, expect, it } from 'vitest';
import type { TourListItem } from '@trek/shared';
import { paddleBench } from '../../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { PaddleCheckSection } from './PaddleCheckSection';
const SAFETY = 'Paddling has risks. Check water, rules and weather yourself.';
/** A planned paddle tour as the list carries it, without a bench. */
const TOUR: TourListItem = {
place_id: 21,
name: 'Naab paddle',
tour_type: 'kayak',
distance: 12.4,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: null,
planned: false,
caution: false,
boat_type: 'canoe',
};
interface Benchless {
id: string;
what: string;
tour: TourListItem;
}
const BENCHLESS: Benchless[] = [
{ id: 'PCS-002', what: 'no bench yet', tour: { ...TOUR, paddle_check: null } },
{ id: 'PCS-003', what: 'a list cached before the bench existed', tour: TOUR },
];
describe('PaddleCheckSection', () => {
it('PCS-001: a tour with a bench shows its pill and its data in one box, the safety note below it', () => {
render(<PaddleCheckSection tour={{ ...TOUR, paddle_check: paddleBench({ data: 'partial', open: 2 }) }} />);
expect(screen.getByText('Checklist')).toBeInTheDocument();
const summary = screen.getByTestId('paddle-check-summary');
expect(within(summary).getByTestId('plan-tour-paddle-check').textContent).toBe('2 open');
expect(within(summary).getByTestId('paddle-check-coverage')).toHaveTextContent('Partial');
expect(within(summary).getByTestId('paddle-check-as-of')).toHaveTextContent('OSM as of');
expect(within(summary).getByTestId('paddle-check-read')).toHaveTextContent('Fetched');
const row = screen.getByRole('button', { name: SAFETY });
expect(summary).not.toContainElement(row);
expect(summary.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it.each(BENCHLESS)('$id: a tour with $what shows the safety note alone', ({ tour }) => {
render(<PaddleCheckSection tour={tour} />);
expect(screen.getByRole('button', { name: SAFETY })).toBeInTheDocument();
expect(screen.queryByText('Checklist')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-summary')).not.toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-check-data')).not.toBeInTheDocument();
});
it('PCS-004: the safety note of the section opens the full note', () => {
render(<PaddleCheckSection tour={{ ...TOUR, paddle_check: paddleBench() }} />);
fireEvent.click(screen.getByRole('button', { name: SAFETY }));
expect(screen.getByRole('dialog', { name: 'Safety on the water' })).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import type { TourListItem } from '@trek/shared';
import { useTranslation } from '../../../i18n';
import { BOX } from '../../Planner/bookings/bookingParts';
import { DialogSection } from '../../shared/DialogShell';
import { PaddleCheckDataLine, PaddleCheckPill } from './PaddleCheckBadges';
import { PaddleSafetyRow } from './PaddleSafety';
/**
* The checklist of a paddle tour in its detail (PAD-09): the check bench as
* the day plan shows it and how current its data is, then the safety note.
* A tour without a bench yet shows the safety note alone.
*/
export function PaddleCheckSection({ tour }: { tour: TourListItem }) {
const { t } = useTranslation();
const check = tour.paddle_check ?? null;
if (!check) {
return (
<div className="flex-none">
<PaddleSafetyRow />
</div>
);
}
return (
<DialogSection className="flex-none" label={t('tours.paddle.check.title')}>
<div className="flex flex-col gap-2">
<div data-testid="paddle-check-summary" className={`${BOX} flex flex-col gap-2 px-3 py-2.5`}>
<div className="flex flex-wrap items-center gap-1">
<PaddleCheckPill check={check} />
</div>
<PaddleCheckDataLine check={check} />
</div>
<PaddleSafetyRow />
</div>
</DialogSection>
);
}
@@ -0,0 +1,48 @@
import type { CSSProperties, SVGProps } from 'react';
import { PADDLE_GLYPH_PATHS, type PaddleGlyphKind } from './paddleGlyphPaths';
/**
* The paddler's own symbols, drawn on lucide's grid (24, stroke 2, round
* caps) so they sit beside its icons without a seam: a lock as a chamber
* between its two mitre gates, a portage as footsteps (lucide's own), a weir
* as water falling over a ledge, a dam as a wall holding water back, rapids
* as broken water, getting in and out as an arrow into or out of the water.
* The waypoint list, the map and the checklist all use them.
*/
export function PaddleGlyph({
kind,
size = 16,
strokeWidth = 2,
...rest
}: { kind: PaddleGlyphKind; size?: number; strokeWidth?: number } & Omit<SVGProps<SVGSVGElement>, 'children'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
data-paddle-glyph={kind}
{...rest}
>
{PADDLE_GLYPH_PATHS[kind].map((d) => (
<path key={d} d={d} />
))}
</svg>
);
}
/** The portage glyph as an icon component, for menus that take one. */
export function PortageIcon(props: { size?: number; style?: CSSProperties }) {
return <PaddleGlyph kind="portage" {...props} />;
}
/** The lock glyph as an icon component, for menus that take one. */
export function LockIcon(props: { size?: number; style?: CSSProperties }) {
return <PaddleGlyph kind="lock" {...props} />;
}

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