Compare commits

..

89 Commits

Author SHA1 Message Date
Maurice 532010aa79 feat(journey): the journal settings list who a linked trip lets in
Under the contributors, the journey settings on desktop and phone get a section of their own, Through trips, with everyone a trip's journal link lets in, their role and the trip they come through. It is read only there, since the trip's settings manage it, and it leaves out whoever is a contributor already. getJourneyFull answers it as trip_access.
2026-10-18 22:04:41 +02:00
Maurice 451167bde3 fix(trips): the settings button as a pill beside the trip's, and the dialog centred
The settings button was a bare round icon next to the trip's back pill; it is the same pill now, with the gear and the word Settings. The settings dialog opens centred again: its fixed height already keeps it from jumping between areas, so it does not need to be pinned to the top.
2026-10-18 22:04:40 +02:00
Maurice 92181cdefb test(trips): the suites follow the settings dialog's areas and the journal link
The dialog's nav buttons are named by their area alone (aria-label), so a hint line naming invites no longer matches the invite button. The sharing cases open the Sharing area first, the wiring and More sheet tests drive the settings button and row, the hook test reads the toasts as keys (renderHook loads no locale), and the lazy list and store registry know the journal panel and store.
2026-10-18 20:49:38 +02:00
Maurice ffa7333bb1 test(journey): the journal link repository on its own, without the two reads nothing called
linkRecipientUserIds and listTripIdsForJourney had no caller and kept src/db/repositories under its 99 % statement floor. TJLREPO-001..007 cover the link rows, the members list, the reach across trips ending with the membership, the hide-skeletons choice of a link member, the role precedence and leaveJournalLink.
2026-10-18 20:40:46 +02:00
Maurice 48d573c60d refactor(trips): leaving a trip tidies its journal link in the roster's own repository
The departure port needed a global binding that every suite building TripMembersModule without AppModule lacked. The roster's repository now deletes the leaver's row on the trip's journal link itself (leaveJournalLink), inside the transaction that removes the member: no edge into journey, no DI, no test stub, and the eight suites the stub touched are back as they were.

The schema inventory tests count the two new tables and their NOT NULL columns, the legacy step map lists the migration as post-legacy, and the TJL scenes give their users numbered names so cases sharing one database do not collide.
2026-10-18 20:02:09 +02:00
Maurice 33c5c7decc test(shared): the event inventory counts trip:journal:changed 2026-10-18 19:23:56 +02:00
Maurice 3db20dd71c chore(client): theme baseline down one for the share button the navbar lost 2026-10-18 19:13:36 +02:00
Maurice 0751b3e8c9 fix(trips): the journal link's toast and tab texts as literal keys
lint:i18n-keys only reaches a key through a literal t() or a *Key property: the three save toasts were passed to run() as bare keys and the tab label came out of TRIP_TAB_LABEL_KEYS, so four new en keys counted as unused. They are translated where they are named now.
2026-10-18 19:04:10 +02:00
Maurice 4a074ec93c refactor(journey): the link role from the generated entity, not a second union
TripJournalLinksRepository spelled 'viewer' | 'editor' by hand while the generated entity exports the same roles, which knip then counted as an unused export. The repository now derives its type and values from the entity.
2026-10-18 18:54:02 +02:00
Maurice a6da69ee36 refactor(trips): the member departure port in common/, where both sides may import it
Declared in trip-members, the port gave journey an edge into the roster, which reaches journey through other domains: the cycle again, the other way round. In the shared kernel neither domain owns it.
2026-10-18 18:44:02 +02:00
Maurice ce8d1e5a0a refactor(trips): leaving a trip reaches the journal link through a port, not an import
TripMembersService called TripJournalLinkService directly, which made trip-members and journey depend on each other (lint:boundaries). The roster now declares TRIP_MEMBER_DEPARTURE and calls memberLeft in the transaction that removes the member; the journey domain binds it globally (TripMemberDepartureModule), the way JourneyAccessModule binds the gateway's JOURNEY_ACCESS. Suites that build TripMembersModule alone import MemberDepartureStubModule.
2026-10-18 18:31:26 +02:00
Maurice 340d429415 test(trips): the journal link stub beside the other journey share helpers
budget.service.db and trips.service tests are held at their size baselines; the stub now comes from journey-share-repos, which both already import, so neither suite grows.
2026-10-18 18:17:04 +02:00
Maurice 89c96ed554 fix(trips): the embedded journal's offsets as classes on its wrapper, not in index.css
index.css is held at its line baseline. The two variables the embedded journal overrides (--nav-h on the desktop, --m-safe-top on the phone) now sit as arbitrary properties on the wrapper that needs them.
2026-10-18 18:04:34 +02:00
Maurice 889e88c362 refactor(trips): the journal member choice in a hook of its own, the test seed without an await loop
useTripJournalLink had grown past the 120-line limit for a function. The member list (candidates per journal, roles, who is new) is now useJournalMemberChoice. What loaded is kept with the journal it belongs to, so a new pick shows nothing until its own people are in, without resetting state by hand. The integration test adds its trip members with Promise.all.
2026-10-18 17:55:41 +02:00
Maurice fe05afec19 feat(trips): trip settings with a linked journal tab
The Share button becomes the trip's Settings, a round button beside the back
pill. The dialog is rebuilt around three areas (People, Sharing, Journal) with
a side navigation, a fixed height and 960 px width on wide screens.

Journal links one of your journals from the Journey addon to the trip. Every
trip member is ticked as a reader by default, each can be raised to editor or
left out, and the people a save would let in for the first time are named
before it saves. The link can be changed or removed. With a link the trip gets
a Journal tab on desktop and phone that shows the Journey addon's own view,
live through its sync and trip:journal:changed. Nothing shows with the addon
off.

members.shareTrip and nav.share are gone with the button that used them.
2026-10-18 17:41:25 +02:00
Maurice 89f3055a29 fix(journey): one journal link per trip as a unique trip id, factories within tsc's depth
The link table keyed by the trip id made both entities a one-to-one, and the
test typecheck failed with TS2589 in the itinerary factory. It now holds its
own id with trip_id UNIQUE, as trip_invite_tokens does, and its members hang
off link_id.

The factory error was not this table's alone: any further relation on trips,
journeys or users tipped the same check. insertRow and createRow now take a
caller's overrides as their own argument instead of a spread checked against
RequiredEntityData, so a new relation no longer breaks the itinerary and tour
factories.

The suites that hand-build TripMembersService get a journalLinksStub.
2026-10-18 17:41:18 +02:00
Maurice 80b6739362 feat(journey): a trip can link a journal and open it to the members it picks
A trip can now show one journal from the Journey addon as its own: any
journal its owner picks, and one journal on as many trips as they like.
The trip members chosen for the link get into the journal as viewers or
editors, see it in their own journal list and follow it live, for as long
as the link holds them and they stay on the trip.

That access is never copied into journey_contributors. It is read where
journal access is read: the contributor repository answers with the
stronger of a direct role and a link role, the journal list and the live
update recipients take the link members in, and the photo access check
goes the same way. Every one of those reads starts from linkReach, which
only counts members still on the linking trip, so unlinking, deselecting
someone and their leaving the trip all end exactly what the link gave.
A member who leaves is also taken off the link, so coming back later does
not quietly reopen the journal.

Linking or changing the members takes the journal's owner and the new
journal_link trip permission (trip owner by default); unlinking takes
either. /api/trips/:tripId/journal answers the link as each member may see
it, /candidates says who a journal would be opened to for the first time,
and four MCP tools do the same over the same service. A trip:journal:changed
event tells the trip to look again.
2026-10-18 15:59:33 +02:00
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
Maurice 309bef4db7 fix(ferratas): leaving 3D with via ferratas on no longer crashes, the removed map is not asked for its layers 2026-10-14 23:37:58 +02:00
Maurice 6e59240328 test(tours): follow the reworked planner, ferrata and map stage in their suites; keep the map's waypoint list stable between renders 2026-10-14 23:01:52 +02:00
Maurice bdb082d490 test(ferratas): a way without geometry really has none, the tile cache's columns join the NOT NULL count 2026-10-14 22:15:45 +02:00
Maurice 8957503dbe test(i18n): the tours locale count follows the ferrata and planner strings 2026-10-14 21:41:21 +02:00
Maurice 0a50119c52 chore(layout): lower the navbar's theme baseline to what it holds now 2026-10-14 21:24:15 +02:00
848 changed files with 64805 additions and 2399 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,
+3 -3
View File
@@ -55,14 +55,14 @@
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 23,
"components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3,
"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),
};
+31
View File
@@ -0,0 +1,31 @@
import type {
TripJournalCandidatesResponse,
TripJournalLinkMemberRequest,
TripJournalLinkResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
* and who it is open to. In its own file rather than in the `client.ts` god
* module; repo/tripJournalLinkRepo.ts is the one caller.
*/
export const tripJournalApi = {
get: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
link: (
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> =>
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
/** The journals this person can open; the dialog offers the ones they own. */
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
apiClient.get('/journeys').then((r) => r.data),
};
+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)),
};
+4
View File
@@ -93,6 +93,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events.
'docsync:changed',
// Linked journal: components/Trips/useTripJournalLink asks again which journal
// the trip shows and who it is open to. Content-free, because that answer is
// per person (a member left off the link must not learn its title).
'trip:journal:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
@@ -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,
};
}
@@ -24,7 +24,7 @@ export const CATEGORIES = [
{ id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
];
/**
@@ -5,20 +5,12 @@ import type { Ferrata } from '@trek/shared';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance } from '../../utils/units';
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
import { MAP_CARD_GLASS } from '../Map/mapControlShadow';
import { SoftPill } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { ferrataOsmUrl } from './ferrataModel';
/** The map controls' frosted glass, so the card reads as one of them. */
const MAP_GLASS = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
};
/**
* One via ferrata, picked on the map: its name, the route it belongs to, its
* grade range and total length as badges, and the way back to the source.
@@ -57,7 +49,7 @@ export default function FerrataDetailCard({
return (
<article
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
style={{ ...MAP_GLASS, width }}
style={{ ...MAP_CARD_GLASS, width }}
data-testid="ferrata-detail-card"
>
<div className="flex items-start gap-2">
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useRef } from 'react';
import { useMapCardDismiss } from '../Map/useMapCardDismiss';
import FerrataDetailCard from './FerrataDetailCard';
import type { FerrataGroup } from './ferrataGroups';
import type { FerrataSelection } from './useFerrataMapLayer';
@@ -39,26 +40,7 @@ export default function FerrataDetailOverlay({
onTourIds?: ReadonlySet<string>;
}) {
const ref = useRef<HTMLDivElement>(null);
const open = !!selection;
useEffect(() => {
if (!open) return;
const onDown = (event: PointerEvent) => {
const card = ref.current;
if (card && event.target instanceof Node && card.contains(event.target)) return;
onOutsidePress?.();
onClose();
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !event.defaultPrevented) onClose();
};
document.addEventListener('pointerdown', onDown, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown, true);
document.removeEventListener('keydown', onKey);
};
}, [open, onClose, onOutsidePress]);
useMapCardDismiss(ref, !!selection, onClose, onOutsidePress);
if (!selection) return null;
return (
@@ -91,17 +91,18 @@ const ferrata = (
id: string,
name: string | null,
level: number | null,
options: { routeName?: string | null; lat?: number; plus?: '+' | null } = {}
options: { routeName?: string | null; lat?: number; lng?: number; plus?: '+' | null } = {}
): Ferrata => {
const lat = options.lat ?? 46.5;
const lng = options.lng ?? 11.7;
return {
id,
name,
grade: { raw: level === null ? null : String(level), level, plus: options.plus ?? null },
routeName: options.routeName ?? null,
geometry: [
[lat, 11.7],
[lat + 0.01, 11.71],
[lat, lng],
[lat + 0.01, Math.round((lng + 0.01) * 1e6) / 1e6],
],
osmUrl: `https://www.openstreetmap.org/${id}`,
};
@@ -141,7 +142,8 @@ describe('FerrataLayerLeaflet', () => {
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade range, or falls back to the route and unnamed', () => {
const groups = groupFerratas([
ferrata('way/1', 'Pisciadù', 2),
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, plus: '+' }),
// Picks up where way/1 ends: one ferrata in two segments.
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, lng: 11.71, plus: '+' }),
ferrata('way/3', null, 3, { routeName: 'Sentiero attrezzato', lat: 47 }),
ferrata('way/4', null, null, { lat: 48 }),
]);
@@ -183,7 +185,7 @@ describe('FerrataLayerLeaflet', () => {
root.style.setProperty('--map-ferrata-lit', 'navy');
const groups = groupFerratas([
ferrata('way/1', 'Via A', 2),
ferrata('way/2', 'Via A', 3, { lat: 46.51 }),
ferrata('way/2', 'Via A', 3, { lat: 46.51, lng: 11.71 }),
ferrata('way/3', 'Via B', 3, { lat: 47 }),
]);
const { rerender } = render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
@@ -49,11 +49,11 @@ describe('routeFerrataCrossings', () => {
});
it('FE-FERRATA-CROSS-004: a short gap in the data does not split the stretch', () => {
const route = northFrom([47.4, 11.0], 20);
// The route steps 25 m aside for two points, still within the join gap.
route[10] = [route[10][0], 11.0004];
route[11] = [route[11][0], 11.0004];
expect(routeFerrataCrossings(route, groups)).toHaveLength(1);
// Two pieces of one ferrata with 44 m missing between them: the route point
// in the middle of the hole is out of reach, the ones beside it are not.
const pieces = groupFerratas([ferrata(2, [47.4, 11.0], 10), ferrata(3, [47.4 + 14 * STEP, 11.0], 11)]);
expect(pieces).toHaveLength(1);
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 25), pieces)).toHaveLength(1);
});
});
+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,5 +1,5 @@
/**
* FE-FERRATA-GL-001 to FE-FERRATA-GL-012: the via ferrata layer on a
* FE-FERRATA-GL-001 to FE-FERRATA-GL-013: the via ferrata layer on a
* MapLibre or Mapbox GL map.
*
* Pinned: the layers go in once per map (and wait for `idle` when tiles are
@@ -37,14 +37,14 @@ const HIT = 'trek-ferratas-hit';
const LABEL = 'trek-ferratas-label';
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT];
const ferrata = (id: string, lat: number, name: string): Ferrata => ({
const ferrata = (id: string, lat: number, name: string, lng = 11.7): Ferrata => ({
id,
name,
grade: { raw: '2', level: 2, plus: null },
routeName: null,
geometry: [
[lat, 11.7],
[lat + 0.01, 11.71],
[lat, lng],
[lat + 0.01, lng + 0.01],
],
osmUrl: `https://www.openstreetmap.org/${id}`,
});
@@ -52,7 +52,7 @@ const ferrata = (id: string, lat: number, name: string): Ferrata => ({
/** Two ferratas: way/1 and way/2 are one (same name, touching), way/3 another. */
const GROUPS = groupFerratas([
ferrata('way/1', 46.5, 'Via A'),
ferrata('way/2', 46.51, 'Via A'),
ferrata('way/2', 46.51, 'Via A', 11.71),
ferrata('way/3', 47, 'Via B'),
]);
@@ -64,7 +64,7 @@ function fakeGlMap(styleLoaded = true) {
const images = new Set<string>();
const handlers = new Map<string, Set<Handler>>();
const once = new Map<string, Set<Handler>>();
const state = { styleLoaded, hit: null as null | { properties: { id: string; group: string } } };
const state = { styleLoaded, removed: false, hit: null as null | { properties: { id: string; group: string } } };
const canvas = document.createElement('canvas');
const container = document.createElement('div');
const keyOf = (type: string, layer?: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type);
@@ -77,7 +77,12 @@ function fakeGlMap(styleLoaded = true) {
removeSource: vi.fn((id: string) => {
sources.delete(id);
}),
getLayer: vi.fn((id: string) => (layers.has(id) ? {} : undefined)),
// Like MapLibre: a removed map has no style, and asking it for a layer throws.
getStyle: vi.fn(() => (state.removed ? undefined : { version: 8 })),
getLayer: vi.fn((id: string) => {
if (state.removed) throw new TypeError('can\'t access property "getLayer", this.style is undefined');
return layers.has(id) ? {} : undefined;
}),
addLayer: vi.fn((spec: { id: string }) => {
layers.add(spec.id);
}),
@@ -267,6 +272,15 @@ describe('useFerrataLayerGL', () => {
expect(() => renderHook(() => useFerrataLayerGL(null, GROUPS, vi.fn()))).not.toThrow();
});
it('FE-FERRATA-GL-013: a map already removed with its view (leaving 3D) is left alone on unmount', async () => {
const gl = fakeGlMap();
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
gl.state.removed = true;
expect(() => unmount()).not.toThrow();
expect(gl.listeners()).toBe(0);
});
it('FE-FERRATA-GL-010: a theme change repaints the installed layers without rebuilding them', async () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata', 'black');
@@ -136,6 +136,9 @@ function addFerrataIcon(map: Map, paint: FerrataPaint, gone: () => boolean): voi
}
function removeFerrataLayers(map: Map): void {
// A map already removed with its view (leaving 3D) took its style and the layers with it;
// asking it for a layer then throws.
if (!map.getStyle?.()) return;
for (const layer of [...LAYERS].reverse()) if (map.getLayer(layer)) map.removeLayer(layer);
for (const source of [LABEL_SOURCE, SOURCE]) if (map.getSource(source)) map.removeSource(source);
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
@@ -49,7 +49,7 @@ describe('useFerrataMapLayer', () => {
expect(result.current.layer.active).toBe(false);
expect(result.current.layer.count).toBe(0);
expect(result.current.selected).toBeNull();
expect(lastCall()).toEqual([7, null, false, null]);
expect(lastCall()).toEqual([7, null, false, null, []]);
});
it('FE-FERRATA-MAPLAYER-002: an area reported while off is remembered without a re-render, and used once it is on', () => {
@@ -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)
}
@@ -98,6 +98,19 @@ describe('JourneySettingsDialog', () => {
await screen.findByRole('button', { name: /create share link/i })
})
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
const { unmount } = mountDialog()
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
unmount()
mountDialog(buildJourney({
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
}))
expect(screen.getByText('Through trips')).toBeInTheDocument()
expect(screen.getByText('lena')).toBeInTheDocument()
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
})
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
const user = userEvent.setup()
const { onSaved } = mountDialog()
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
import JourneyShareSection from './JourneyShareSection'
import { useJourneySettings } from './useJourneySettings'
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import ToggleSwitch from '../Settings/ToggleSwitch'
@@ -277,6 +277,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</div>
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
</DialogSection>
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
</div>
</div>
@@ -324,3 +326,32 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</>
)
}
/**
* Who a trip's linked journal lets in (#2683), under its own heading below the
* contributors: shown here, but managed in that trip's settings, so read only.
*/
function TripAccessSection({ access, roleLabel }: {
access: JourneyTripAccess[] | undefined
roleLabel: (role: JourneyContributor['role']) => string
}) {
const { t } = useTranslation()
if (!access?.length) return null
return (
<DialogSection label={t('journey.contributors.viaTrips')}>
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
<div className={ROWS}>
{access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
</div>
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
</div>
))}
</div>
</DialogSection>
)
}
+14 -18
View File
@@ -169,23 +169,24 @@ describe('Navbar', () => {
expect(adminLink).toHaveAttribute('href', '/admin');
});
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
render(<Navbar onShare={vi.fn()} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
expect(shareBtn).toBeInTheDocument();
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i });
const settings = screen.getByRole('button', { name: 'Trip settings' });
expect(back.parentElement).toBe(settings.closest('div'));
});
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
const user = userEvent.setup();
const onShare = vi.fn();
render(<Navbar onShare={onShare} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
await user.click(shareBtn);
expect(onShare).toHaveBeenCalled();
const onSettings = vi.fn();
render(<Navbar onSettings={onSettings} />);
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
expect(onSettings).toHaveBeenCalled();
});
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
render(<Navbar />);
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
});
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
});
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i });
fireEvent.mouseEnter(back);
@@ -462,11 +463,6 @@ describe('Navbar styling and menu details', () => {
fireEvent.mouseLeave(back);
expect(back.style.background).toBe('transparent');
const share = screen.getByRole('button', { name: /share/i });
fireEvent.mouseEnter(share);
expect(share.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(share);
expect(share.style.background).toBe('var(--bg-card)');
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
fireEvent.mouseEnter(theme);
+19 -14
View File
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
import HelpButton from '../Help/HelpButton'
@@ -21,7 +21,8 @@ interface NavbarProps {
tripId?: number | string
onBack?: () => void
showBack?: boolean
onShare?: () => void
/** The trip's Settings: who is on it, its links and its journal. */
onSettings?: () => void
}
interface Addon {
@@ -32,7 +33,7 @@ interface Addon {
enabled: boolean
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
@@ -173,6 +174,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
</span>
))}
{/* The trip's Settings sit with the trip, beside its pill: they are about this
trip, not about the app the right side of the bar belongs to. */}
{onSettings && (
// The same pill as the trip's own beside it, so the two read as one pair.
<button
type="button"
onClick={onSettings}
aria-label={t('trip.settings.title')}
className="inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card py-1 ps-2 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Settings className="h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
<span className="text-caption font-semibold">{t('nav.settings')}</span>
</button>
)}
</div>
{/* Centred liquid-glass tab menu (design handoff).
@@ -245,17 +261,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
itself on top of them. */}
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
{/* Share button */}
{onShare && (
<button type="button" onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
{user && <HelpButton />}
+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 -21
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,14 +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 · komoot.com')
expect(row).toHaveTextContent('1 h 35 min')
expect(row.querySelector('[data-testid="tour-description-preview"]')).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', () => {
@@ -97,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/ }))
@@ -199,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={[]}
@@ -214,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={[]}
@@ -239,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>;

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