Compare commits

...

75 Commits

Author SHA1 Message Date
Maurice e3268735f2 ci: fold the gate jobs into two per package, so the long jobs fit the 20 runners
With three gate jobs per package a code change started ~29 jobs, 22 of
them running for minutes, against the organisation's 20 concurrent jobs.
Two test shards waited three minutes for a slot and the run took 9.9
minutes instead of 8.5. Server lint and checks, and client types and
checks, now share a job each (about 4-5 minutes, still shorter than a
shard), which leaves 20 long jobs. The note at the top of test.yml says
to count them before adding one.
2026-10-20 08:35:41 +02:00
Maurice 9a59703a34 docs(ci): complete the job list in test.yml and follow the renamed parity job
The list at the top of test.yml now names every step group its jobs hold,
the contributing guide names the parity job as it is called now, and
db-mock.ts says test-db reaches the auth controller only when a rate limit
is reset.
2026-10-20 08:27:01 +02:00
Maurice 200ce58e98 test(server): import the auth controller only when a rate limit is reset
test-db.ts imported AuthPublicController at the top for resetRateLimits,
which pulled the auth graph (bcrypt, otplib, nodemailer, the MCP SDK) into
every file using the helper; most never reset a rate limit. Every caller
already awaits the helper.
2026-10-20 08:22:40 +02:00
Maurice a965b2c75e perf(shared): keep the DOMPurify sanitisers out of the root barrel
isomorphic-dompurify builds a jsdom window as soon as it loads on Node,
and the root barrel is what the server and each of its ~900 test files
import, so every one of them loaded jsdom without ever sanitising. The
sanitisers move behind @trek/shared/sanitize (a build entry of its own,
like roadtrip), escapeHtml stays in the root from its own module, and
the client's translation context imports the sanitiser from the subpath.
The production server stops loading jsdom at boot as well.
2026-10-20 08:22:40 +02:00
Maurice 91a834010b ci(sonar): leave the React root and the mascot videos out of the analysis
Every scan spent 300 of its 450-830 seconds in the JS security sensor's
React render simulation, which starts at client/src/main.tsx and ran into
its fixed time limit each time. main.tsx holds the stylesheets and the
mount call; what it renders is analysed through its own files. The .mp4
clips under client/src were read as text and only produced encoding
warnings.
2026-10-20 08:22:40 +02:00
Maurice a8d06193bd ci: split the gates into types, lint and checks, and name every job by package
The two quality jobs ran 8-9 minutes each and became the floor of the run
once the tests were sharded. Each is now three jobs (types, lint, checks)
holding the same steps in the same order. Plugin facts moves into the
server checks and the SDK typecheck into the SDK coverage job, both a few
seconds each. Every job is named "<package>: <what>", and the top of
test.yml lists what each one holds.

The client and server suites run in seven shards each, which evens out
the slices (server shards ran up to 6.5 min at six, client ones 3-5 at
eight). Client jobs install shared and client in one npm ci instead of two.

The swc step is replaced by a check: the lockfile carries the Linux
binary, and the step's npm install --legacy-peer-deps pruned eight peer
packages, so the server tests ran on a tree the lockfile does not describe.
2026-10-20 08:22:40 +02:00
Maurice 97b748555f ci: run the tests beside the gates, and the client and server suites in shards
The test jobs waited for the quality jobs (~9 min) and the client suite ran
on one runner (20-35 min), so a run took 45-60 min before ci-ok answered.
Every test job now starts right after changes. The client suite runs in
eight slices and the server suite in six, each with coverage into a blob
report; client-coverage and server-coverage merge the blobs, check the
thresholds against the whole and upload the lcov the scan reads. The scan
needs the gates directly, so a failed gate still stops it.
2026-10-20 07:57:23 +02:00
Maurice 187c4e2fc8 style(tours): format the GPX activity helper 2026-10-19 23:25:26 +02:00
Maurice dfd02940b5 test(tours): ask the trail grade cache one request after another, without an await in a loop 2026-10-19 23:12:30 +02:00
Maurice da0f79e285 test(tours): cover a bike tour's GPX activity and the trail grade cache's limit 2026-10-19 22:59:59 +02:00
Maurice e877d35e0b fix(tours): name the surface, way and trail classes as types only 2026-10-19 22:22:55 +02:00
Maurice f3a39d9452 fix(tours): keep the bike paints, classes and costing to their modules, and pin the profile column to the contract 2026-10-19 22:15:22 +02:00
Maurice b374fdc989 fix(tours): reach the Overpass client through its service file, and format the take-over list 2026-10-19 22:03:54 +02:00
Maurice b8eb2d909e fix(tours): drop two unused test imports, and keep the take-over list clear of its scrollbar 2026-10-19 21:53:45 +02:00
Maurice e429b49ef6 fix(tours): the repository test names the bike profile, and the add menu reads its list outside render 2026-10-19 21:44:19 +02:00
Maurice 73237ffedb feat(tours): bike tours in the planner, CyclOSM, and the planned cards' last points (BIK-01 to BIK-06, RTE-01, IO-01, LIB-02)
The tour type is a dropdown; a bike tour picks its profile, routes for it,
says when a point lies off every way, and shows what it runs on: surfaces,
kinds of way and, for a mountain bike, the trail grades, each pointed at on
the map. A type's own map (OpenHikingMap for a hike, CyclOSM for a bike tour)
is offered while a tour of that type is open, with an admin switch each.

From the planned cards: a hike avoids steep climbs, steps or ferries, nothing
else; a saved tour can be shared where the platform takes GPX files; and the
tours rail adds a tour from one menu: plan new, import GPX, or take one over
from another trip.
2026-10-19 21:33:49 +02:00
Maurice adcaca3c5f feat(tours): bike tours in the contract and on the server (BIK-01 to BIK-06, RTE-01)
A tour can be a bike tour with a routing profile (touring, road, gravel,
mtb), stored per tour by a new migration. The server grades the ways of a
mountain bike route from OpenStreetMap (mtb:scale) through one shared
Overpass client, offers CyclOSM behind its own admin switch, and exports a
bike tour's GPX with the activity of its profile. A hike's route choices
are now exactly the three the card names: steep climbs, steps and ferries.
2026-10-19 21:33:40 +02:00
Maurice c3229afcba fix(tours): sort the place categories of a search key by their names 2026-10-19 20:38:49 +02:00
Maurice 12e7bdf6e8 test(tours): the library reads and the forgotten water network, each at the repository 2026-10-19 19:02:40 +02:00
Maurice 9382fa6dc6 test(tours): place the store of the places found along a tour in the test registry 2026-10-19 18:27:53 +02:00
Maurice 3332495b7a test(tours): send the refused place searches one after another
Sent at once, the requests reset each other's connection on CI.
2026-10-19 17:25:59 +02:00
Maurice 75d8351fec fix(tours): export only what is imported, and keep the planner page at its size 2026-10-19 17:00:21 +02:00
Maurice ce07c4dc72 fix(tours): keep the planner page at its size, and format the GPX test
The rail reads the trip's tour ids from the workspace it already gets,
so the page line stays as it was; the map's size entry follows the
lines it lost.
2026-10-19 16:49:03 +02:00
Maurice c5c88691bc fix(tours): hold the new code to the server's warning baseline
A stop's link to a tour is checked for all its points at once, and the
tests read the values they assert on without non-null assertions.
2026-10-19 16:35:57 +02:00
Maurice 827e682684 feat(tours): places along the tour, and a found place as a stop (PLN-02, PLN-03)
The editor gains a card under the waypoints: along the tour. It offers what
the tour type stops for as chips (a hike viewpoints, huts and shelters,
drinking water, food, summits, toilets, picnic spots, campsites, sights; a
paddle tour put-ins, boat rental and the like, the first three ticked) and
how far from the route to look, 250 m to 2 km. Only its button searches.
Each place found shows how far it lies from the route, straight across, and
where along the route; its pin is on the map in its category's colour, and
a click there opens it in the card. Opened, a place says its kind and
source, its address, opening hours as the source has them with a note to
check them, and its website. The answer says when it is out of date (the
route or the choices changed since), offline, failed, partly searched, cut
short at the first part of a long tour, or more than shown. Nothing about
the tour changes by searching.

An opened place can go in as a stop: after the point chosen, the one before
it along the route suggested, by the button alone, as one step undo takes
back. It becomes a stop of the tour on the place's spot, not a place of the
trip. A leg drawn by hand or a fixed line (a via ferrata, a portage) is not
cut: the point after which it would go says why and offers no button. Once
the new route is in, the card says what the stop added, as the difference
of the route planned before and after; while it is planned it says so, and
where either route could not be planned it says that rather than guess.

The search merges a place OpenStreetMap maps twice, as a point and as its
outline, by its name and spot. The library repository takes its offline
error from a shared online-only module, which the search uses too.
2026-10-19 16:24:59 +02:00
Maurice 11a8d1b4d1 feat(tours): search places along a tour, server and contract (PLN-02)
POST /api/trips/:tripId/tours/poi-search looks for what a hike or a paddle
tour stops for along a line: viewpoints, peaks, shelters, drinking water,
food, toilets, picnic spots, campsites and sights, and on the water
slipways and boat rental. The line comes in the body, so a draft not yet
saved can be searched, thinned to at most 1000 points; the corridor is
50 m to 2 km wide.

The line is cut into stretches as the road trip's corridor search does,
each searched through the map search (the TREK places index first,
OpenStreetMap after), three at a time; a long line is searched along its
first twelve stretches and the answer says so (capped). The places kept
lie within the corridor of the line itself, each once, nearest the start
first, up to 60, each with its source, kind, address, website, phone and
opening hours as the source has them, its distance straight across to the
line (distance_m, never called a detour) and how far along it lies. A
stretch that fails leaves the others' places and marks the answer partial;
a cut answer is truncated. Nothing is written. It is a read, so anyone who
sees the trip may ask.

The map search's OSM filters gain those outdoor categories; the explore
pill does not offer them. MCP: search_tour_pois, along a saved tour's line.
2026-10-19 15:40:36 +02:00
Maurice 692373c8b8 feat(tours): take a tour over from another trip, the dialog (LIB-02)
The Tours tab's list of the trip's tours gains a button beside the GPX
import: take a tour over from another trip. Its dialog lists the tours of
the user's other trips, searched by name and filtered by trip and type,
each with its trip and the measures it has (time, distance, ascent; one it
lacks is left out, a paddle tour's climb too). Choosing one shows it on a
map with where it comes from, its type, distance, time, heights and number
of waypoints, and the name it gets in this trip, its own name to start
with. The note under the name says the copy is a tour of this trip of its
own, on no day yet, and that later changes stay apart.

Only the button in the foot takes it over. The new tour then comes into
the trip's lists and map and opens in the editor, unless the editor holds
unsaved work, which it never replaces. Nothing is written before the
confirm, and closing writes nothing. The library is online only: offline,
or when the list fails, the dialog says so and offers to try again; a list
cut at 100 tours says to search or filter.

A tour taken over names its source in its provenance: "Taken over from"
with the trip and the tour as they were called then.
2026-10-19 15:28:53 +02:00
Maurice f6fd2722d0 feat(tours): take a tour over from another trip, server and contract (LIB-02)
GET /api/tours/library lists the tours of every trip the user owns or is a
member of, with the trip each is in and how many tours each trip holds, to
choose one to take over into the trip being planned. It is narrowed by a
part of the name (matched ignoring case, a "%" or "_" just a character), a
type or one trip, and the asking trip can be left out; up to 100 tours, and
the answer says when there were more. GET /api/tours/library/:placeId
previews one: its facts and its line thinned to 400 points, its ends kept.
Neither is trip-scoped, so every answer is bounded by the user's own trips,
read anew each call: a tour of a trip out of reach is "not found", nothing
hints that it exists. Measures stay null where the tour has none.

POST /api/trips/:tripId/tours/take-over copies one into this trip, which the
guard checks with 'place_edit'; the source trip is checked against the
user's trips as they are at that moment, and the source is read again inside
the write. The copy is a tour of its own: its line, measures, settings and
control points, without days, without its stops' links to places of the
other trip and without the obstacle decisions taken there; it keeps the
import record of its line. A copy that fails leaves nothing behind.

Where it came from is kept as text (tours.taken_over_from_trip_title and
taken_over_from_tour_name, migration 045100), not as a pointer, and read
back in the provenance as takenOverFrom; a copy of such a copy names it too.
The in-trip copy (P2C-07) and the take-over share one copy routine.

MCP: list_tour_library and take_over_tour, over the same service and
checks. The repository tests follow the hiking preferences column of MF-08.
2026-10-19 14:53:08 +02:00
Maurice 9cd227fd81 feat(tours): fold the rail cards, remembered across reloads; trip totals as badges
The planner's cards fold to their head: waypoints, the paddle time estimate,
the wind and the checklist on the left, the open tour's summary on the right.
The title or the chevron folds a card, and the choice is kept in this
browser, so a reload finds the rails as they were left. A folded card still
says what matters in its head: the checklist what is open, the wind its day,
and the time estimate now its total as a badge, in the warning tone while
parts of it are unknown. The "planning values" badge and info dot of the
estimate go; what the starting values are is said once under its figures.
The obstacle entry dialog sits outside the checklist's body, so an obstacle
picked on the map still opens with the card folded.

The trip totals start folded to their counts and show each type's sums as
the row's white badges, each named in its tooltip, an incomplete one marked
as at least that much with how many tours it is known for. The info text is
shorter.

The Tours locale contract counts the 574 general keys the planned cards
brought in.
2026-10-19 14:18:28 +02:00
Maurice e4b49e1148 fix(tours): count the trip totals' tours on a day from the day lists (STA-01)
The totals counted a tour as on a day by the list's planned flag, which
lags behind a day assignment made a moment ago. They read the day lists
now, as the row's day badge does, so the count follows at once.
2026-10-19 13:56:05 +02:00
Maurice a87ce65899 feat(tours): the trip's tour totals by type above the tour list (STA-01)
The Tours tab's list of the trip's tours gains the filter the plan's tour
list has, and above the list a card with what the tours shown add up to:
how many, how many are on a day and how many not, then per type distance,
ascent and descent, and the three kinds of time apart, the router's for
tours planned in TREK, the one an import brought, and the planned one with
breaks. Nothing is added up across types. The filter narrows the list and
the totals alike.

Each tour counts once however many days it is on; the list is one row a
tour. A value no tour has is left out rather than shown as zero, and a sum
some tours lack is shown as at least that much, its info dot saying for how
many of how many it is known. Paddling sums no heights: on the water a climb
says nothing, and a planned paddle tour's heights would be the banks'.

The card folds to its counts, remembered in this browser. The empty state
for a filter that leaves nothing is shared by both lists now.
2026-10-19 13:55:07 +02:00
Maurice 06e6e25b25 feat(tours): OpenHikingMap as a hiking base map of the tour planner (MAP-01)
While a hike is planned, the planner's map switch offers "Hiking" beside the
map, topo and satellite: OpenHikingMap, with marked trails over the terrain.
It is a choice of the planner's view alone, like topo: the global map
setting, the tour and its days stay as they are. With no tour open, over an
import viewed as it came in and for a paddle tour it is not offered, and a
choice made earlier falls back to the standard map there.

openmaps.fr's tile usage policy is followed: tiles load only as the map
shows them, with the browser's Referer, from the server's own caching; no
service worker rule keeps them and the 3D flyover's warm-up passes their
source by. Its credit and donation link are on the map; the 3D map, whose
attribution is folded away under the panel, shows them as a line of its own
above its controls.

The service is best effort. A run of failed tiles while it is shown marks
the switch, with a tooltip and a status for screen readers that say it is
not answering and that the tour is untouched; one tile arriving clears it.
The tile health the seamark overlay had is now useTileHealth, shared by both.

The source is free for non-commercial installs up to 500,000 tiles a month.
tours_hiking_map_enabled is an admin switch under Tours (admin tours-hiking-map,
audited), on by default, for an install that is commercial or larger to
switch off until it has an agreement with the operator. /api/addons carries
it as toursHikingMap, true only while Tours is on.

Leaflet, GL and the 3D map read one table of the planner's raster bases
(tourRasterBases), so topo and the hiking map are drawn the same way.
tile.openmaps.fr is on the CSP's connect-src for the GL maps.
2026-10-19 13:07:36 +02:00
Maurice d322363e53 feat(tours): adding a point and picking a trip place as one quiet bar
The two ways to add a stop sat side by side as dashed boxes of different
widths, and the first changed its words while the map waited for a click,
which cut them off. They are now the two halves of one bar in the look of
the editor's other switches: equal width, the one in use raised, the label
the same in both states. While the map waits for a click the half is raised
with its icon in the accent, and its tooltip says to click the map.
2026-10-19 13:07:19 +02:00
Maurice a20b682087 refactor(tours): hold the planner to the client's warning baseline
The last rounds left the client a few warnings over its baseline. Opening a
saved tour reads its editable fields through openedTourFields instead of
spelling out each default in the handler; the status badge's words come from
statusLabelOf; the hooks that adopt or refuse a revision name it in their
dependencies; the hiking preferences' ref is kept by the setters alone, as
the limit's is; the paddle notes' pane is made before the first line instead
of through a state set in an effect; the tour workspace is back to its
length; and a test reads the walking time without a non-null assertion.

A shaping-point test follows the route choices handed to the router (MF-08).
2026-10-19 13:07:19 +02:00
Maurice ad86713fdc feat(tours): route choices for a hike, each checked against the router (MF-08)
A hike can ask the router for more than its difficulty limit: avoid steps,
avoid ferries, prefer footpaths, shorter over faster. They sit under the
limit in the planner's details as switches, each with what it really does,
and the info dot says they weigh the way and give way where there is no
other, and that none grades a path. Switching one plans the route again as
one step of the history, and the choice is kept in the draft.

Only preferences the public Valhalla was seen to honour are offered, with
the measured cases beside hikingCostingOptions: step_penalty took Würzburg
to the Festung from four flights to one, use_ferry 0 sent Kiel round the
Förde and Hamburg through the Elbe tunnel, walkway_factor moved Garmisch to
the Partnachklamm onto the paths, shortest cut the way to the Eckbauer.
use_hills and use_tracks showed no effect on foot and are left out, as are
marked, paved and rough ways, for which the router has no option.

tours.hiking_preferences (migration 045000) holds them as JSON, NULL for
none, so no tour saved before routes differently. Only a hike keeps them;
a paddle tour naming one is refused. A copy carries them, and list_tours
and get_tour_plan read them.

The trip copy's waypoint links now follow the copied places inside
TourWaypointsRepository.insertForPlace.
2026-10-19 11:31:31 +02:00
Maurice 66525c66b7 fix(tours): paddle notes under the route, and a lost water network heals itself
The lines of the paddle notes (a rule over a whole stretch, an obstacle's
extent) were drawn over the tour's own route, so where a rule covered the
river the route under it could not be seen. They now go under it: on
Leaflet in a pane placed right before the overlay pane (the app levels every
pane, so the order decides), on MapLibre/Mapbox before the route's casing.
The markers stay on top, where they are picked.

A region whose network file was gone answered every leg with "The route
planner is busy", which no retry could change. The router now forgets such
a network and its legs say the area has no water data, so loading the
region (PAD-11C) builds the network again and the planner plans on.

The boot sweep left a database without any network alone instead of
clearing every file it did not know: a test's empty database, or one
restored without its data folder, no longer throws away networks another
database still names.
2026-10-19 11:29:29 +02:00
Maurice 7a484a09d4 feat(tours): stops that stand for places of the trip (PR1-10)
A tour's waypoint can stand for a place of its trip, chosen on purpose from
"Trip places" in the planner: searchable, nearest to the tour first, grouped
into near the tour and the rest, and a place already in the tour says so. A
pick adds the stop on the place's spot as one step of the history; the row
names it after the place and says it is a place of the trip. The point keeps
its own coordinates, the place stays a place of its own and its days are
left alone. Moving the point or "Unlink from the place" drops the link;
removing it never touches the place.

tour_waypoints.linked_place_id (migration 044900) references places with
ON DELETE SET NULL, so a deleted place only unlinks. A save checks the links
inside its transaction: a tour of the trip is refused, a place that is gone
or of another trip leaves the point unlinked, the same either way. The trip
copy points the links at the copied places, the GPX export writes the linked
stops as named waypoints, and get_tour_plan names them.

nearnessToLine in @trek/shared measures a point against a route, for the
picker and for the search along a route that comes next.
2026-10-19 10:52:26 +02:00
Maurice d72352911e feat(tours): tour revisions, conflicts and a live editor (P2C-03)
A saved tour now has a technical revision, raised inside the transaction of
every write that changes it: the route and its controls, the plan laid onto a
recorded paddle track, going back to that track. A write that names the
revision it was based on (expected_revision) is refused with a 409
{ code: 'tour_revision_conflict', revision } before anything of it is
written, so a stale draft never overwrites a newer tour unseen. A write that
names none writes as before, so older clients and queued offline writes keep
working; the queue never carries a base. A copy starts at 1, a source's
version stays provenance. Migration 044800 adds the column; list_tours and
get_tour_plan read it.

The editor follows the trip's tours live: a newer revision of the open tour
loads at once while the editor has no changes of its own, and shows a notice
while it has. A refused save keeps the draft and asks: save it as a new tour,
load the newer version, or overwrite on purpose.

useTourPlanner was at the size limit; its derived facts, undo history and
draft storage move into tourPlannerFacts, usePlannerHistory and
useTourDraftStorage, unchanged in behaviour.
2026-10-19 10:22:04 +02:00
Maurice 134ab64bce feat(tours): download a saved tour as a GPX file (PR1-06A)
A saved tour can be handed over as a GPX file on purpose: its whole saved
line as one track with the heights it has and none it lacks, its name,
description and link, and the activity in <type>. The file is named after
the tour. A tour without a line of two points is refused with 422 rather
than written as a file that imports as nothing; the export changes nothing.

GET /api/trips/:tripId/tours/:placeId/export.gpx, readable by anyone who
sees the trip, and the MCP tool export_tour_gpx with the same rules. The
tour detail gets a "GPX" action beside the navigation, the planner head a
download button that waits while the tour has unsaved changes.

The tours rail head now uses the same plain icon buttons as the editor.
2026-10-19 09:42:13 +02:00
Maurice e29aa99269 fix(tours): a lake shortcut costs no more than the stretch of path it replaces
A shortcut was held to the dearer of its two end cells, so an end out on open water lifted the ceiling just as a bank end did before: a leg from a point out on a lake was pulled straight across, through water the search had gone round, for a SUP beyond its reach and for the shore style beyond its band. The same happened midway once a kept vertex lay in water past the boat's reach. A shortcut is now priced along its line on open water and taken only when it costs no more than the stretch of cell path it replaces (with 5 % to spare); the shore distance check stays as it was.

Measured on the WATER-005 lake and a larger one with a bay and a spit: legs from bank to bank are as before within a few metres, except a kayak no longer crosses 409 m of water past its reach that the search avoided; from a point 500 m out a SUP covers 321 m past its reach (300 m cannot be avoided) instead of 1301 m, a canoe 133 m instead of 1132 m, and the shore style follows the bank. WATER-012 holds the case from a point out on the lake.
2026-10-18 22:54:41 +00:00
Maurice 7907963e03 test(tours): the region load's failure paths and Geofabrik's size question are tested
The paddle obstacles domain held 90 % of its branches, under its 92 % floor, because the new region load and size question had their failure paths untested. Now covered: a notice to the admins that fails never undoes the request, a list out of reach with something other than an error, the smallest of several held regions, a request filed meanwhile that is not announced twice, and sizeOf asked with HEAD, unknown without a usable size or answer, and refused for an address the list could not name.
2026-10-18 22:35:33 +00:00
Maurice 45b9f07238 fix(tours): a lake leg along the shore stays along it when it starts at the bank
WATER-005 found the shore style crossing straight over like the direct one. Pulling a cell path straight allowed a shortcut through water as costly as its dearer end, and since the clearance ramp a cell at the bank costs up to five times, so a leg from a river mouth lifted that ceiling above the open water and was pulled across the lake. A shortcut is now held to the cost of the open water alone (the style's band and the boat's reach); how near the bank it may come was already held by the path it replaces. The search costs are unchanged.

Measured on the test lake and a larger one with a bay and a spit: direct, canoe and SUP legs are exactly as before; along the shore the leg no longer crosses (median 98 to 161 m off the bank, was up to 500), and no more samples on land than before.
2026-10-18 22:35:33 +00:00
Maurice d1ec6fb145 test(tours): the inventories count the region requests and the new admin notice
ENT-003 counts 142 entities and CLASSFIELD-002 494 uninitialised NOT NULL scalars with paddle_region_requests (region_id, name, reason) in, and NPREF-005b expects paddle_region_requested among the admin scoped events.
2026-10-18 22:05:44 +00:00
Maurice 961d223c7c test(tours): the paddle obstacles e2e gets the realtime service and the storage paths
The notices to the admins brought NotificationsModule into PaddleObstaclesModule, which needs RealtimeService and opens the storage registry at init. The suite now mounts RealtimeModule with a FakeRealtimeService, as the tours and wanderer suites do, and its DataPathsService stand-in names an uploads and a backups folder in its temp dir.
2026-10-18 22:05:44 +00:00
Maurice faabc533c6 fix(tours): the paddle obstacles module imports DemoModule for the region load
PaddleRegionLoadService injects DemoService, which only the app graph provided through the @Global DemoModule. A TestingModule that pulls PaddleObstaclesModule in through ToursModule (tours.paddle-check, tours.mcp, wanderer e2e) could not resolve it. The module now imports DemoModule itself, as BudgetModule and FeedsModule do.
2026-10-18 22:05:44 +00:00
Maurice bb39745a23 fix(planner): the reorder days dialog opens centred
It was pinned to the top of the window; centred it sits where the other short dialogs open.
2026-10-18 23:39:54 +02:00
Maurice c9e1e8ad86 test(tours): the reasons a region request waits for are pinned to the contract
knip found PaddleRegionRequestsReason exported and imported nowhere. Like the boat, water and lake style columns, the CHECK list of the column (044700) is now held against paddleRegionWaitReasonSchema, so a reason one side knows and the other refuses fails a test instead of an insert.
2026-10-18 21:35:02 +00:00
Maurice 9301053c56 fix(tours): paddle region settings go through the settings service, both values in one transaction
lint:boundaries refused PaddleRegionLoadService injecting the AppSettings repository, which the settings domain owns. SettingsService now reads a registered instance setting (getAppSetting) and writes several of them in one transaction (setAppSettings), and the region load asks it instead.

That also puts the two writes of updateSettings into one transaction, which lint:tx asked for. PLOAD-013 shows that a failed second write leaves the first one unchanged.

load is tagged @txIndependent: it decides one region after the other with Geofabrik asked for a size in between, and a region added or a request filed stands on its own, so no transaction may span it.
2026-10-18 21:35:01 +00:00
Maurice cda6e4bfd1 style(tours): the repo test's busy leg wrapped the way Prettier writes it
The client format gate refused client/src/repo/paddleRouteRepo.test.ts, a new file outside the format baseline.
2026-10-18 21:35:01 +00:00
Maurice dcc1c18fa9 fix(tours): the lint the paddle work grew, back to the baseline
The region load and obstacle helpers move out of their component files (paddleRegionLoad.ts, paddleObstaclePlace.ts), so those files export components only (react-refresh). TourPlannerMapStage takes the take-from-card rule out into paddlePlaceFromCard and works out the cards' end inset once (complexity 24, back under 20). The tour tests reach list items through at() and must() instead of non-null assertions, a position change in the shaping test is voided rather than left floating, and PLOAD-004 seeds its regions with Promise.all.
2026-10-18 21:21:32 +00:00
Maurice 7e9297d867 test(tours): a failed leg in the repo test carries its at, as the contract has it 2026-10-18 21:21:32 +00:00
Maurice a19d67e5a2 fix(tours): lake legs keep off the shore instead of hugging it one cell out
The lake cost only added half again within 12 m of the bank, so the cheapest line round a bend ran one grid cell off the water's edge (7 to 19 m on the big lakes), and pulling it straight could draw it over land. A clearance ramp now makes the water nearer than 80 m (40 m along the shore) steadily dearer, which also centres the route in anything narrower than twice that, the shore style rides a band 100 to 350 m out instead of the bank itself, and a shortcut may not come nearer the shore than the path it replaces. Measured on nine lakes: no sample on land (was up to two), direct keeps 51 to 78 m at its closest (was one cell), along the shore the median is 60 to 100 m (was 30 to 40) and under 1 % of it within 25 m (was up to 22 %); lengths change by under 1 %.
2026-10-18 21:21:32 +00:00
Maurice cd64a2f542 test(tours): type the region load answer in the repo test as the contract's literal 2026-10-18 21:21:32 +00:00
Maurice 1bc82f97fd feat(tours): route paddle tours on the water, shaping points and auto-loaded regions
Paddle tours are planned on the water by TREK's own router: a water network
per paddle region, built from the same Geofabrik extract as the obstacles and
searched in a worker thread of the server (no sidecar). Rivers, canals and
lakes (direct or along the shore), portages round weirs, locks by policy,
whitewater by boat, gaps marked. Found portages and locks become points of
their own; an unplannable leg says why and offers a way out.

- Shaping points: a click on the line sets a point that steers the route
  without being a stop (hikes and paddle tours); recompute a single leg.
- Lake style direct/shore, suggested from the wind forecast.
- Regions load by themselves where a paddler plans (admin setting: automatic,
  on request, off; size cap and a daily count), with requests for the admins,
  a notification, and the load followed on the leg.
- Portage from the last point, portages follow footways via the walking
  router (or stay straight), "put in here" on slipways and put-ins.
- MCP plan_paddle_route with the REST route's rules.
2026-10-18 21:21:32 +00:00
Maurice b76a298bb3 ci(lint): a base version the typed rules cannot lint no longer crashes the report
On the server, linting a file's base text with type-aware rules threw (no program for a text that is not on disk), and the TypeError ended the whole ratchet run. A lint that throws now counts as unknown: the file's current warnings of the rule are told, marked as not compared.
2026-10-18 23:17:33 +02:00
Maurice 17029d364a ci(lint): lintTextClient stays inside the client ratchet
Only check() uses it, as its default; exported it was an unused export to knip, which turned the server quality job on dev red.
2026-10-18 23:16:37 +02:00
Maurice f458278e88 ci(lint): a grown warning count names the file and line that grew it
lint:warnings failed with the total and the files holding the most of the rule, which are old ones the baseline allows; the file that tipped the count was never named, so finding it meant linting by hand. When a rule grows, the ratchet now lints the files that differ from the base twice, as they are and as they were there, and lists every one holding more of the rule, line and message. The base is TREK_LINT_BASE (the CI fetches the pushed-over commit on a push and the target branch on a pull request) or origin/dev; without it the report stays as it was.
2026-10-18 22:46:00 +02:00
Maurice fca42d46d9 refactor(journey): the phone's trip access list as its own component
The list of who a linked trip lets in took MJourneySettingsSheet past the 300-line limit for a component (max-lines-per-function), which turned the client lint on dev red. It is MTripAccessList beside it now; the markup is unchanged.
2026-10-18 22:36:57 +02:00
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
771 changed files with 38907 additions and 1748 deletions
+335 -143
View File
@@ -9,23 +9,73 @@ permissions:
# analysis; pull requests get PR analysis with decoration on the PR itself
# (the scanner picks the context up from the Actions environment).
#
# Job layout — three tiers:
# - Preflight: plugin-facts, i18n-parity and the quality jobs
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
# of them, so a gate failure stops the run before the expensive coverage
# fan-out spends ~15 runner-minutes on a result the gates already sank.
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
# runs the MinIO contract suite. Coverage jobs run tests only — with the
# gates cleared in preflight, they can only fail because tests failed.
# client-e2e drives the real app in Chromium and WebKit, client-bundle
# builds the client and checks each map engine keeps a chunk of its own,
# and postgres-probe runs the dialect helpers and the repository
# statements it reaches against a Postgres service. None of them feeds the scan.
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
# must stop the scan — a missing lcov file would make every line of that
# package report as uncovered, and the dashboard would show a collapse
# that did not happen. (The preflight gates stop it transitively.)
# Job names read "<package>: <what>", so the checks list on a PR sorts by
# package. What each job holds:
#
# Repo: Changed Areas decides what the change needs to run (below)
# Shared: i18n Parity every locale has en's files and keys
# Shared: Coverage shared's tests with coverage
# Server: Types builds shared and the server, typechecks shared,
# the server's src, tests and scripts, smokes the
# production require chain, the strict ratchet,
# the generated entities and Kysely types
# Server: Lint & Checks ESLint (typed), errors and warning counts; then
# Prettier, the plugin facts, the DB call graph,
# the shared ratchet code, the source ratchets
# (size, boundaries, dialect, tx, test SQL, test
# mocks, hand-built services, query API, MCP zod,
# error envelopes, response contracts), open
# request schemas, knip
# Server: Tests n/7 one slice of the server suite, with coverage
# Server: Coverage merges the slices, checks the per-domain
# thresholds, uploads lcov
# Server: S3 Contract (MinIO)
# the storage driver against MinIO
# Server: Postgres Probe the repository SQL against Postgres
# Client: Types & Checks typecheck and the strict ratchet; then the page
# pattern and the source ratchets (RTL, size,
# Prettier, layers, offline path, duplication,
# desktop/phone pairs, skips, i18n keys, theme
# tokens)
# Client: Lint ESLint (typed), errors and warning counts
# Client: Tests n/7 one slice of the client suite, with coverage
# Client: Coverage merges the slices, checks the thresholds,
# uploads lcov
# Client: Bundle the production build, one chunk per map engine
# Client: End-to-End the app in Chromium and WebKit
# Plugin SDK: Types & Coverage
# Repo: Docker Image builds the image and boots it until ready
# Repo: Helm Chart & Compose
# Repo: Sonar Scan SonarCloud analysis, not required
# ci-ok the one required check (below)
#
# Job layout:
# - Everything starts right after changes: the gates and the tests run side
# by side, so a run takes as long as its slowest job and not the gates
# plus the tests. The tests used to wait for the gates, which put ~9
# minutes in front of every test result. The gates were one job per
# package (~8-9 minutes each); each is now two jobs of ~4-5 minutes,
# split where the time is (the server's typechecks, the client's lint),
# so neither outlasts a test shard.
# - The client and server suites run in shards (client-tests,
# server-tests), each running a slice with coverage and uploading a blob
# report; client-coverage and server-coverage merge the blobs, check the
# thresholds against the whole and upload lcov. A red shard is usually a
# failing test, a red merge job usually a threshold. None of the other
# test jobs feeds the scan.
# - Job count: GitHub runs at most 20 jobs of the organisation at once and
# does not say in which order it starts queued ones. A code change starts
# the 14 shards and about a dozen other jobs; seven of those (parity, the
# SDK, the bundle, S3, Postgres, shared coverage, the chart) are done in
# a minute or two, so what queues at the start waits about that long. The
# jobs that run for minutes (shards, gates, e2e, the image) must not
# outnumber the 20 slots, or one of them waits for another to finish and
# the run grows by a whole job: a layout with six gate jobs did exactly
# that. Check the count before adding a long job or a shard.
# - Scan `needs:` the gates and the four coverage jobs. A coverage job
# failing must stop the scan — a missing lcov file would make every line
# of that package report as uncovered, and the dashboard would show a
# collapse that did not happen.
# - Fork PRs and runs Dependabot triggers don't receive the repo's Actions
# secrets, so the two jobs that need them skip themselves: s3-contract
# (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN). ci-ok counts a skip as
@@ -38,7 +88,7 @@ permissions:
# required check that never reports blocks the PR for good.
# - docker-image builds the Dockerfile and boots the result, and
# deploy-manifests lints and renders the Helm chart and checks the
# compose files. Both run beside the preflight rather than after it.
# compose files. Both run beside the gates rather than after them.
# - ci-ok `needs:` every gate, test, image and chart job, and is the one
# check the branch ruleset requires. It fails when any of them failed or
# was cancelled and passes when they passed or were skipped (by changes,
@@ -69,7 +119,7 @@ concurrency:
jobs:
changes:
name: Changed Areas
name: 'Repo: Changed Areas'
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
@@ -144,36 +194,8 @@ jobs:
node scripts/ci/changed-areas.mjs --all
fi
plugin-facts:
name: Plugin Permission Facts
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace server
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
i18n-parity:
name: i18n Key Parity
name: 'Shared: i18n Parity'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -189,8 +211,8 @@ jobs:
- name: Check i18n key parity
run: node shared/scripts/i18n-parity.mjs --strict
server-quality:
name: Server Types & Lint
server-types:
name: 'Server: Types'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -257,6 +279,36 @@ jobs:
# migration that changes a column regenerates it in the same change.
run: cd server && npm run check:db-types
server-lint-checks:
name: 'Server: Lint & Checks'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Build shared
run: npm run build --workspace=shared
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint
# Errors fail, and the warnings per rule may not pass
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
@@ -264,8 +316,19 @@ jobs:
# program, which outgrows Node's default ~4 GB heap on the runner.
env:
NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd server && npm run lint:warnings
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
- name: Formatting check
# Prettier over src/ and tests/: a file outside
# scripts/format-baseline.json must be formatted, and the list only
@@ -359,12 +422,12 @@ jobs:
# knip (knip.jsonc) over client, server and shared: unused files,
# exports, types and dependencies per file may not rise above
# scripts/ci/knip-baseline.json (scripts/ci/knip-ratchet.mjs). Here
# rather than in client-quality because it needs every workspace's
# dependencies, which only this job installs.
# rather than in client-types-checks because it needs every workspace's
# dependencies, which the client jobs do not install.
run: npm run lint:knip
client-quality:
name: Client Types & Lint
client-types-checks:
name: 'Client: Types & Checks'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -380,7 +443,7 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
run: npm ci --workspace shared --workspace client
- name: Build shared
run: npm run build --workspace=shared
@@ -395,15 +458,6 @@ jobs:
# one included, has to be clean (scripts/strict-lint.mjs).
run: cd client && npm run lint:strict
- name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json.
# Typed linting (no-floating-promises, no-misused-promises) loads the
# whole TypeScript program, so it gets the heap the server lint gets.
env:
NODE_OPTIONS: --max-old-space-size=8192
run: cd client && npm run lint:warnings
- name: Page pattern check
run: cd client && npm run lint:pages
@@ -467,36 +521,13 @@ jobs:
# follow the user's scheme, accent and text size (scripts/theme-lint.mjs).
run: cd client && npm run theme:lint
plugin-sdk-quality:
name: Plugin SDK Types
client-lint:
name: 'Client: Lint'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
client-bundle:
name: Client Bundle
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -507,7 +538,47 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
run: npm ci --workspace shared --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json.
# Typed linting (no-floating-promises, no-misused-promises) loads the
# whole TypeScript program, so it gets the heap the server lint gets.
env:
NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd client && npm run lint:warnings
client-bundle:
name: 'Client: Bundle'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
- name: Build shared
run: npm run build --workspace=shared
@@ -523,10 +594,11 @@ jobs:
run: cd client && npm run check:gl-split
client-e2e:
name: Client End-to-End
name: 'Client: End-to-End'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -580,17 +652,17 @@ jobs:
retention-days: 7
s3-contract:
name: S3 Driver Contract (MinIO)
name: 'Server: S3 Contract (MinIO)'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
needs: changes
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and
# the AIStor container would fail to boot. A run Dependabot triggers gets
# only Dependabot secrets, so its PRs (same repo, not a fork) hit the same
# wall. Pushes and manual dispatches by anyone else do have secrets, so
# only those two are filtered out. (The implicit success() on this
# expression also keeps the job skipped when a preflight fails.)
# only those two are filtered out.
if: >-
needs.changes.outputs.code == 'true' &&
github.actor != 'dependabot[bot]' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
@@ -606,14 +678,14 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Check @swc/core's native binding loads
# The server's SWC transform (server/vitest.config.ts) needs it. The
# lockfile carries the Linux binary, so npm ci installs it; this only
# names the cause if a lockfile regenerated without it ever lands. (It
# used to be installed here with --legacy-peer-deps, which pruned
# eight peer packages and left the tests on a tree the lockfile does
# not describe.)
run: node -e "require('@swc/core').transformSync('let a = 1')"
- name: Build shared
run: npm run build --workspace=shared
@@ -671,10 +743,10 @@ jobs:
# artifact seeds the file and a change that fixes a statement can lower it
# without a local Postgres.
postgres-probe:
name: Postgres Probe
name: 'Server: Postgres Probe'
runs-on: ubuntu-24.04
timeout-minutes: 10
needs: [changes, plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
needs: changes
if: needs.changes.outputs.code == 'true'
services:
postgres:
@@ -727,11 +799,20 @@ jobs:
if-no-files-found: ignore
retention-days: 14
server-coverage:
name: Server Coverage
# The server suite in seven slices. vitest.shard.config.ts is vitest.config.ts
# without the thresholds and the coverage reports: a slice's blob carries
# its coverage to server-coverage, which checks the whole. fail-fast is off,
# so one red run shows every failing slice and not just the first.
server-tests:
name: 'Server: Tests ${{ matrix.shard }}/7'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
steps:
- uses: actions/checkout@v6
@@ -744,20 +825,63 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Check @swc/core's native binding loads
# The server's SWC transform (server/vitest.config.ts) needs it. The
# lockfile carries the Linux binary, so npm ci installs it; this only
# names the cause if a lockfile regenerated without it ever lands. (It
# used to be installed here with --legacy-peer-deps, which pruned
# eight peer packages and left the tests on a tree the lockfile does
# not describe.)
run: node -e "require('@swc/core').transformSync('let a = 1')"
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests with coverage
run: cd server && npm run test:coverage
- name: Run a slice of the tests with coverage
run: >-
cd server && npx vitest run --config vitest.shard.config.ts --coverage
--shard=${{ matrix.shard }}/7
--reporter=default --reporter=github-actions --reporter=blob
- name: Upload the blob report
uses: actions/upload-artifact@v6
with:
name: server-blob-${{ matrix.shard }}
path: server/.vitest-reports/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
server-coverage:
name: 'Server: Coverage'
needs: server-tests
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Download the blob reports
uses: actions/download-artifact@v7
with:
pattern: server-blob-*
path: server/.vitest-reports/
merge-multiple: true
- name: Merge the slices and check the thresholds
# Every blob holds a whole coverage map, and the merge reads them all
# into one process.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: cd server && npx vitest run --merge-reports --coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
@@ -766,11 +890,18 @@ jobs:
path: server/coverage/
retention-days: 1
client-coverage:
name: Client Coverage
# The client suite in seven slices, the same way as server-tests. The two
# suites cost about the same per slice at seven each (~4-6 minutes).
client-tests:
name: 'Client: Tests ${{ matrix.shard }}/7'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
steps:
- uses: actions/checkout@v6
@@ -785,13 +916,56 @@ jobs:
# @testing-library/react) in the tree without the re-resolution dance
# the folded coverage job used to need.
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
run: npm ci --workspace shared --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests with coverage
run: cd client && npm run test:coverage
- name: Run a slice of the tests with coverage
run: >-
cd client && npx vitest run --config vitest.shard.config.ts --coverage
--shard=${{ matrix.shard }}/7
--reporter=default --reporter=github-actions --reporter=blob
- name: Upload the blob report
uses: actions/upload-artifact@v6
with:
name: client-blob-${{ matrix.shard }}
path: client/.vitest-reports/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
client-coverage:
name: 'Client: Coverage'
needs: client-tests
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
- name: Download the blob reports
uses: actions/download-artifact@v7
with:
pattern: client-blob-*
path: client/.vitest-reports/
merge-multiple: true
- name: Merge the slices and check the thresholds
# Every blob holds a whole coverage map, and the merge reads them all
# into one process.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: cd client && npx vitest run --merge-reports --coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
@@ -801,10 +975,11 @@ jobs:
retention-days: 1
shared-coverage:
name: Shared Coverage
name: 'Shared: Coverage'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -827,11 +1002,14 @@ jobs:
path: shared/coverage/
retention-days: 1
plugin-sdk-coverage:
name: Plugin SDK Coverage
# Typecheck and tests in one job: each takes seconds, and a runner of its
# own would cost more in setup than it saves.
plugin-sdk:
name: 'Plugin SDK: Types & Coverage'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
defaults:
run:
working-directory: plugin-sdk
@@ -850,6 +1028,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Run tests with coverage
run: npm run test:coverage
@@ -861,7 +1042,7 @@ jobs:
retention-days: 1
scan:
name: Scan
name: 'Repo: Sonar Scan'
runs-on: ubuntu-24.04
timeout-minutes: 45
# Fork PRs and Dependabot runs don't receive SONAR_TOKEN, so skip the scan
@@ -871,9 +1052,18 @@ jobs:
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
# Coverage jobs failing must stop the scan: a missing lcov file would make
# every line of that package report as uncovered, and the dashboard would
# show a collapse that did not happen. The preflight gates already stop it
# transitively through these four, so they aren't repeated here.
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
# show a collapse that did not happen. The gates run beside the tests now,
# so they are listed here too, and a failed gate still stops the scan.
needs:
- i18n-parity
- server-types
- server-lint-checks
- client-types-checks
- client-lint
- server-coverage
- client-coverage
- shared-coverage
- plugin-sdk
steps:
- uses: actions/checkout@v6
@@ -934,7 +1124,7 @@ jobs:
# reached dev unseen. It is separate from the vulnerability scan on purpose,
# so a newly published CVE cannot hide a broken build or the other way round.
docker-image:
name: Docker Image
name: 'Repo: Docker Image'
needs: changes
if: needs.changes.outputs.image == 'true'
runs-on: ubuntu-24.04
@@ -1011,7 +1201,7 @@ jobs:
# the output checked against the Kubernetes API schemas. Both tools come
# from their release pages, pinned by version and checksum.
deploy-manifests:
name: Helm Chart & Compose
name: 'Repo: Helm Chart & Compose'
needs: changes
if: needs.changes.outputs.deploy == 'true'
runs-on: ubuntu-24.04
@@ -1082,19 +1272,21 @@ jobs:
ci-ok:
needs:
- changes
- plugin-facts
- i18n-parity
- server-quality
- client-quality
- plugin-sdk-quality
- server-types
- server-lint-checks
- client-types-checks
- client-lint
- client-bundle
- client-e2e
- s3-contract
- postgres-probe
- server-tests
- server-coverage
- client-tests
- client-coverage
- shared-coverage
- plugin-sdk-coverage
- plugin-sdk
- docker-image
- deploy-manifests
# always(): without it a failed dependency would skip this job, and the
+2
View File
@@ -55,6 +55,8 @@ coverage
coverage-*/
*.lcov
.nyc_output
# Blob reports of a sharded vitest run (test.yml)
.vitest-reports
# Cache
.npm
+3 -1
View File
@@ -134,8 +134,10 @@ These principles come out of a full-repo audit and shape all new code:
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-quality), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-lint-checks), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
- **Job names read "package: what"** (`Server: Types`, `Client: Tests 3/7`), and the list at the top of `test.yml` says what each job holds. A new job follows the scheme and joins that list, and the job-count note there says when a long job needs a slot freed first.
- **Tests run beside the gates, in shards.** The client and server suites run in slices on parallel runners (`client-tests`, `server-tests`, through each package's `vitest.shard.config.ts`), and `client-coverage`/`server-coverage` merge their blob reports and check the thresholds against the whole. A red slice is usually a failing test, a red merge job usually a threshold.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
+2 -2
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 875,
"@typescript-eslint/no-misused-promises": 874,
"no-console": 88,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 147,
@@ -12,7 +12,7 @@
"react-hooks/set-state-in-effect": 197,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121,
"@typescript-eslint/no-unused-vars": 130,
"@typescript-eslint/no-unused-vars": 129,
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"react-hooks/static-components": 78,
+91 -4
View File
@@ -2,6 +2,7 @@
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
*/
import { ESLint } from 'eslint';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import ts from 'typescript';
import {
@@ -137,6 +138,90 @@ export async function lintClient(root) {
}));
}
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
function gitIn(root, args) {
try {
return execFileSync('git', args, {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 1 << 28,
});
} catch {
return null;
}
}
/** The messages of one rule in one area of a single file's lint result. */
function ofRule(results, rule, area) {
const result = results[0];
if (!result) return [];
const pool =
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
return pool.filter((m) => ruleOf(m) === rule);
}
/**
* Where a grown rule's new warnings sit: every file among `keys` (the files
* holding the rule now) that differs from `ref` and holds more of the rule
* than it did there, each of its warnings of the rule as `file:line message`.
* The count alone names no file, and the files holding the most are old ones;
* this names the file that tipped it. Null when `ref` is not at hand (a clone
* without it), and the report then keeps to the files holding the most.
*/
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
if (changed === null) return null;
const differs = new Set(changed.split('\n').filter(Boolean));
const found = await Promise.all(
keys
.filter((key) => differs.has(key))
.map(async (key) => {
const path = join(root, key);
// A typed rule can trip over a text that is not the file on disk (the
// base version); then the file's own warnings are told, marked so.
const lint = async (text) => {
try {
return { messages: ofRule(await lintText(text, path), rule, area) };
} catch {
return null;
}
};
const now = await lint(readText(path));
if (!now) return [];
const before = git(root, ['show', `${ref}:./${key}`]);
const was = before === null ? { messages: [] } : await lint(before);
if (was && now.messages.length <= was.messages.length) return [];
const note = was ? '' : ' (the base version could not be linted)';
return now.messages.map((m) => `${key}:${m.line ?? 0} ${m.message}${note}`);
})
);
return found.flat();
}
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
async function reportNew(grown, { root, files, lintText, error }) {
if (!grown.length || !lintText) return;
const found = await Promise.all(
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
);
grown.forEach(({ area, rule }, i) => {
const lines = found[i];
if (!lines?.length) return;
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
for (const line of lines) error(` ${line}`);
});
}
/** Lints one file's text with the client's own config, for newWarningsOf. */
function lintTextClient(root) {
const eslint = new ESLint({ cwd: root });
return (text, filePath) => eslint.lintText(text, { filePath });
}
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
/**
@@ -148,6 +233,7 @@ export async function check({
baselinePath = join(root, 'scripts/eslint-baseline.json'),
update = false,
lint = lintClient,
lintText = lint === lintClient ? lintTextClient : null,
log = console.log,
error = console.error,
}) {
@@ -160,13 +246,13 @@ export async function check({
}
for (const line of errors) error(`FAIL ${line}`);
let grown = 0;
const grown = [];
let stale = 0;
for (const area of AREAS) {
for (const [rule, n] of Object.entries(counts[area])) {
const allowed = baseline[area][rule] ?? 0;
if (n <= allowed) continue;
grown++;
grown.push({ area, rule });
const top = Object.entries(files[area][rule])
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
@@ -182,7 +268,8 @@ export async function check({
});
stale += fallen.length;
}
if (grown) {
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
if (grown.length) {
error(
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
'Run npx eslint <file> to see each one.'
@@ -191,5 +278,5 @@ export async function check({
log(
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
);
return errors.length || grown || stale ? 1 : 0;
return errors.length || grown.length || stale ? 1 : 0;
}
+2 -2
View File
@@ -5,9 +5,9 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1409,
"src/components/Map/MapView.tsx": 1401,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2299,
"src/components/Map/MapViewGL.tsx": 2296,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
+1 -1
View File
@@ -55,7 +55,7 @@
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 22,
"components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3,
+13
View File
@@ -0,0 +1,13 @@
import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } 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-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */
export const paddleRegionLoadApi = {
load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise<PaddleRegionLoadState[]> =>
apiClient
.post('/paddle/regions/load', request, { signal, ...GEOFABRIK })
.then((r) => paddleRegionLoadResponseSchema.parse(r.data).points),
};
+11 -2
View File
@@ -2,17 +2,19 @@ import {
paddleRegionCatalogResponseSchema,
paddleRegionListResponseSchema,
paddleRegionSchema,
paddleRegionSettingsSchema,
successResponseSchema,
type PaddleRegionRequest,
type PaddleRegionSettings,
} 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. */
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
export const paddleRegionsApi = {
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
catalog: (q: string, signal?: AbortSignal) =>
apiClient
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
@@ -29,4 +31,11 @@ export const paddleRegionsApi = {
apiClient
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)),
decline: (id: string) =>
apiClient
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)),
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
saveSettings: (settings: PaddleRegionSettings) =>
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
};
+10
View File
@@ -0,0 +1,10 @@
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
import apiClient from './client';
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
export const paddleRouteApi = {
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
apiClient
.post(`/trips/${tripId}/paddle/route`, request, { signal })
.then((r) => paddleRouteResponseSchema.parse(r.data)),
};
+16
View File
@@ -0,0 +1,16 @@
import type { AdminFeatureToggleResponse } from '@trek/shared';
import apiClient from './client';
/** An admin's switch for a map the Tours planner offers on its operator's terms. */
function tourBaseMapAdminApi(path: string) {
return {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data),
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put(path, { enabled }).then((r) => r.data),
};
}
/** MAP-01: OpenHikingMap for a hike. */
export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map');
/** BIK-06: CyclOSM for a bike tour. */
export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map');
+33
View File
@@ -0,0 +1,33 @@
import apiClient from './client';
/**
* The file name a download's Content-Disposition gives: the UTF-8 `filename*`
* first, since it keeps the tour's own script, the plain `filename` after.
*/
export function attachmentFilename(header: unknown): string | null {
if (typeof header !== 'string') return null;
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1];
if (encoded) {
try {
return decodeURIComponent(encoded.trim());
} catch {
// A malformed escape falls through to the plain name.
}
}
return /filename="([^"]+)"/i.exec(header)?.[1] ?? null;
}
/** PR1-06A: one saved tour as the GPX file the server writes. */
export const tourGpxExportApi = {
download: async (
tripId: number | string,
placeId: number,
signal?: AbortSignal
): Promise<{ blob: Blob; filename: string | null }> => {
const response = await apiClient.get<Blob>(`/trips/${tripId}/tours/${placeId}/export.gpx`, {
responseType: 'blob',
signal,
});
return { blob: response.data, filename: attachmentFilename(response.headers['content-disposition']) };
},
};
+18
View File
@@ -0,0 +1,18 @@
import type {
TourCreateResponse,
TourLibraryDetailResponse,
TourLibraryQuery,
TourLibraryResponse,
TourTakeOverRequest,
} from '@trek/shared';
import apiClient from './client';
/** LIB-02: the tours of the user's other trips, one's preview, and taking one over into a trip. */
export const tourLibraryApi = {
list: (query: TourLibraryQuery, signal?: AbortSignal): Promise<TourLibraryResponse> =>
apiClient.get('/tours/library', { params: query, signal }).then((r) => r.data),
detail: (placeId: number, signal?: AbortSignal): Promise<TourLibraryDetailResponse> =>
apiClient.get(`/tours/library/${placeId}`, { signal }).then((r) => r.data),
takeOver: (tripId: number | string, body: TourTakeOverRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/take-over`, body).then((r) => r.data),
};
+16
View File
@@ -0,0 +1,16 @@
import type { TourPoiSearchRequest, TourPoiSearchResponse } from '@trek/shared';
import apiClient from './client';
/**
* PLN-02: places along a tour. A search runs several stretches against the map
* search on the server, each allowed its own timeout there, so it may take
* longer than the shared default.
*/
const POI_SEARCH_TIMEOUT_MS = 60_000;
export const tourPoiSearchApi = {
search: (tripId: number | string, body: TourPoiSearchRequest, signal?: AbortSignal): Promise<TourPoiSearchResponse> =>
apiClient
.post(`/trips/${tripId}/tours/poi-search`, body, { signal, timeout: POI_SEARCH_TIMEOUT_MS })
.then((r) => r.data),
};
+20
View File
@@ -0,0 +1,20 @@
import type { TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared';
import apiClient from './client';
/**
* BIK-05: the trail grades of the ways a mountain bike tour runs on. The
* server asks Overpass in parts, one after another, so a long tour may take
* longer than the shared default.
*/
const TRAIL_GRADE_TIMEOUT_MS = 60_000;
export const tourTrailGradeApi = {
grades: (
tripId: number | string,
body: TourTrailGradeRequest,
signal?: AbortSignal
): Promise<TourTrailGradeResponse> =>
apiClient
.post(`/trips/${tripId}/tours/trail-grades`, body, { signal, timeout: TRAIL_GRADE_TIMEOUT_MS })
.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),
};
+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',
+20 -7
View File
@@ -77,6 +77,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
if (loading) {
@@ -155,13 +156,25 @@ 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()}
/>
<>
<AddonSubRow
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
key={key}
icon={<Icon size={14} />}
title={t(titleKey)}
description={t(subtitleKey)}
enabled={enabled}
onToggle={() => void toggle()}
/>
))}
</>
)
}
// The AI-parsing settings hang off their tile like every other sub-feature,
@@ -2,11 +2,13 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { adminApi } from '../../../api/client';
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer';
import { useAddonStore } from '../../../store/addonStore';
import type { Addon } from './addonModel';
import { useAddonManager } from './useAddonManager';
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-006
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-008
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
@@ -35,6 +37,9 @@ beforeEach(() => {
useAddonStore.setState({ loadAddons });
vi.spyOn(adminApi, 'addons').mockResolvedValue({ addons: ADDONS });
vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({});
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
});
afterEach(() => vi.restoreAllMocks());
@@ -46,6 +51,13 @@ async function loaded() {
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
/** One of the map switches under Tours, by its key. */
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle') {
const found = manager.mapSwitches.find((entry) => entry.key === key);
if (!found) throw new Error(`no ${key} map switch`);
return found;
}
describe('useAddonManager', () => {
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
const { result, rerender } = await loaded();
@@ -115,4 +127,33 @@ describe('useAddonManager', () => {
await act(() => result.current.documentProviderOptions[0].toggle());
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('gdrive', { enabled: false });
});
it('FE-HOOK-ADDONMGR-007: the hiking map switch under Tours loads, flips, saves and refreshes the store (MAP-01)', async () => {
const update = vi.spyOn(hikingMapAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'hiking').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'hiking').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'hiking').enabled).toBe(false);
expect(loadAddons).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated');
});
it('FE-HOOK-ADDONMGR-009: the cycling map switch under Tours loads, flips and saves on its own (BIK-06)', async () => {
const update = vi.spyOn(cycleMapAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'cycle').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'cycle').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'cycle').enabled).toBe(false);
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
});
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
const { result } = await loaded();
await act(() => result.current.toggleToursWanderer());
expect(result.current.toursWanderer).toBe(false);
expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error');
});
});
@@ -1,6 +1,8 @@
import { Bike, Footprints } from 'lucide-react';
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
@@ -20,6 +22,8 @@ export function useAddonManager() {
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
const [toursHikingMap, setToursHikingMap] = useState(false);
const [toursCycleMap, setToursCycleMap] = useState(false);
const loadAddons = async () => {
try {
@@ -36,6 +40,14 @@ export function useAddonManager() {
.get()
.then((flag) => setToursWanderer(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
hikingMapAdminApi
.get()
.then((flag) => setToursHikingMap(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
cycleMapAdminApi
.get()
.then((flag) => setToursCycleMap(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
});
useEffect(() => {
loadOnMount();
@@ -63,20 +75,44 @@ 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);
/** A switch under Tours (wanderer, OpenHikingMap, CyclOSM): flipped at once, put back when the save fails. */
const flipToursSwitch = async (
current: boolean,
set: (enabled: boolean) => void,
save: (enabled: boolean) => Promise<unknown>
) => {
const next = !current;
set(next);
try {
await wandererAdminApi.update(next);
await save(next);
} catch {
setToursWanderer(!next);
set(!next);
toast.error(t('admin.addons.toast.error'));
return;
}
void refreshGlobalAddons();
toast.success(t('admin.addons.toast.updated'));
};
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
/** The maps the Tours planner offers on their operators' terms, one switch each (MAP-01, BIK-06). */
const mapSwitches = [
{
key: 'hiking',
Icon: Footprints,
titleKey: 'admin.toursHikingMap.title',
subtitleKey: 'admin.toursHikingMap.subtitle',
enabled: toursHikingMap,
toggle: () => flipToursSwitch(toursHikingMap, setToursHikingMap, hikingMapAdminApi.update),
},
{
key: 'cycle',
Icon: Bike,
titleKey: 'admin.toursCycleMap.title',
subtitleKey: 'admin.toursCycleMap.subtitle',
enabled: toursCycleMap,
toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update),
},
] as const;
const asOption = (provider: Addon): ProviderOption => ({
key: provider.id,
@@ -106,5 +142,6 @@ export function useAddonManager() {
documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
};
}
@@ -0,0 +1,167 @@
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
const SIZES = [100, 250, 500, 1000, 2000] as const;
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
function useMegabytes() {
const { language } = useTranslation();
return (mb: number) =>
new Intl.NumberFormat(language, {
style: 'unit',
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
maximumFractionDigits: mb >= 1000 ? 1 : 0,
}).format(mb >= 1000 ? mb / 1000 : mb);
}
const MB = 1024 * 1024;
/**
* PAD-11C: how TREK gets the water data of a place a paddler plans in. The
* mode, and in the automatic one the largest region it loads without asking.
*/
export function AutoLoadSettings({ admin }: { admin: Admin }) {
const { t } = useTranslation();
const megabytes = useMegabytes();
const { settings, saving, save } = admin.autoLoad;
if (!settings) return null;
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
? SIZES
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
return (
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
<div className="min-w-0">
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
<Segmented<PaddleRegionAutoMode>
value={settings.mode}
onChange={(mode) => void save({ mode })}
label={t('admin.paddleRegions.auto.title')}
fill
options={MODES.map((mode) => {
const Icon = MODE_ICONS[mode];
return {
value: mode,
label: t(`admin.paddleRegions.auto.mode.${mode}`),
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
};
})}
/>
</fieldset>
<SettingsHint className="mt-1.5">{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}</SettingsHint>
</div>
{settings.mode === 'auto' && (
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
<ChoiceChips<string>
value={String(settings.max_mb)}
label={t('admin.paddleRegions.auto.maxSize')}
onChange={(value) => void save({ max_mb: Number(value) })}
options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))}
/>
<SettingsHint className="mt-1.5">{t('admin.paddleRegions.auto.maxSizeHint')}</SettingsHint>
</fieldset>
)}
</div>
);
}
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
const { t, language } = useTranslation();
const megabytes = useMegabytes();
const busy = admin.busyId === request.id;
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
return (
<div
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
data-testid="paddle-region-request"
>
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-1">
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
{request.name}
</span>
<span className="flex flex-wrap items-center gap-1.5">
{request.declined ? (
<StatusPill tone="danger" icon={<Ban size={10} strokeWidth={2.4} />}>
{t('admin.paddleRegions.requests.declined')}
</StatusPill>
) : (
<StatusPill tone="warning">{t(`admin.paddleRegions.requests.reason.${request.reason}`)}</StatusPill>
)}
{request.bytes !== null && <StatusPill>{megabytes(Math.max(1, Math.round(request.bytes / MB)))}</StatusPill>}
{request.requested_by && (
<StatusPill>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</StatusPill>
)}
<StatusPill>{date}</StatusPill>
</span>
</div>
<span className="flex flex-none items-center gap-1.5">
{!request.declined && (
<button
type="button"
disabled={admin.busyId !== null}
onClick={() => void admin.decline(request.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{t('admin.paddleRegions.requests.decline')}
</button>
)}
<button
type="button"
disabled={admin.busyId !== null}
onClick={() => void admin.approve(request.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
{t('admin.paddleRegions.requests.approve')}
</button>
</span>
</div>
);
}
/** The regions paddlers asked for that wait for an admin, the open ones first. */
export function RequestList({ admin }: { admin: Admin }) {
const { t } = useTranslation();
if (admin.requests.length === 0) return null;
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
const open = admin.requests.filter((request) => !request.declined).length;
return (
<div className="flex min-w-0 flex-col gap-2" data-testid="paddle-region-requests">
<span className={`${LABEL} mb-0 flex items-center gap-1.5`}>
{t('admin.paddleRegions.requests.title')}
{open > 0 && <StatusPill tone="warning">{open}</StatusPill>}
</span>
<SettingRows>
{ordered.map((request) => (
<RequestRow key={request.id} request={request} admin={admin} />
))}
</SettingRows>
</div>
);
}
/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */
export function AutoOriginPill() {
const { t } = useTranslation();
return (
<Tooltip label={t('admin.paddleRegions.origin.autoHint')}>
<span className="inline-flex" data-testid="paddle-region-auto">
<StatusPill icon={<Sparkles size={10} strokeWidth={2.2} />}>{t('admin.paddleRegions.origin.auto')}</StatusPill>
</span>
</Tooltip>
);
}
@@ -1,4 +1,4 @@
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: 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';
@@ -25,11 +25,13 @@ function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
id: 'oberpfalz',
name: 'Oberpfalz',
status: 'ready',
origin: 'admin',
source_timestamp: '2026-10-03T12:00:00Z',
imported_at: '2026-10-04T08:00:00Z',
feature_count: 1234,
error: null,
progress: null,
network: null,
...overrides,
};
}
@@ -56,6 +58,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
toursOn: true,
offline: false,
regions: [],
requests: [],
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
loading: false,
error: null,
retry: vi.fn(),
@@ -63,6 +67,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
busyId: null,
add: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
approve: vi.fn(async () => {}),
decline: vi.fn(async () => {}),
removing: null,
askRemove: vi.fn(),
cancelRemove: vi.fn(),
@@ -295,6 +301,91 @@ describe('PaddleRegionsPanel', () => {
});
});
describe('PaddleRegionsPanel water network (PAD-11)', () => {
it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => {
const network = {
water_km: 1364.5,
lakes: 266,
portages: 14,
inferred_portages: 232,
gaps: 63,
locks: 17,
built_at: '2026-10-17T15:30:20Z',
bytes: 1496816,
};
show({ regions: [region({ network })] });
const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network');
expect(pill).toHaveTextContent('1,365 km of water');
fireEvent.mouseEnter(pill);
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps'
);
});
it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => {
show({
regions: [
region({ id: 'old', name: 'Old read', network: null }),
region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }),
],
});
expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument();
expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => {
const save = vi.fn(async () => {});
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } });
const auto = screen.getByTestId('paddle-auto-load');
expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true');
expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument();
fireEvent.click(within(auto).getByRole('button', { name: /On request/ }));
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
fireEvent.click(within(auto).getByRole('button', { name: '1 GB' }));
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
});
it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => {
show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } });
expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument();
expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => {
const approve = vi.fn(async () => {});
const decline = vi.fn(async () => {});
const base = {
bytes: 734_003_200,
requested_by: 'elmo',
requested_at: '2026-10-18T09:00:00Z',
};
show({
approve,
decline,
requests: [
{ ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true },
{ ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false },
],
});
const rows = screen.getAllByTestId('paddle-region-request');
expect(rows[0]).toHaveTextContent('Brandenburg');
expect(rows[0]).toHaveTextContent('Daily limit');
expect(rows[0]).toHaveTextContent('by elmo');
expect(rows[1]).toHaveTextContent('Declined');
fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' }));
expect(decline).toHaveBeenCalledWith('brandenburg');
fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ }));
expect(approve).toHaveBeenCalledWith('norway');
expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull();
});
it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => {
show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] });
expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic');
expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull();
});
});
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);
@@ -15,6 +15,7 @@ import { EditorField, INPUT } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import ErrorBoundary from '../../shared/ErrorBoundary';
import { Tooltip } from '../../shared/Tooltip';
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
@@ -22,8 +23,10 @@ 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.
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
* place a paddler plans in by itself, with the regions that wait for an admin.
* 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 (
@@ -67,6 +70,8 @@ function Panel() {
</div>
)}
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
<AutoLoadSettings admin={admin} />
<RequestList admin={admin} />
<CatalogSearch admin={admin} />
<RegionList admin={admin} />
</fieldset>
@@ -216,6 +221,41 @@ function RegionStatus({ region }: { region: PaddleRegion }) {
);
}
/**
* PAD-11: the water network the router follows in a region, as one figure
* with the rest in its tooltip. A region read before routing existed says it
* needs reading again; a network is never claimed where none was built.
*/
function RegionNetwork({ region }: { region: PaddleRegion }) {
const { t, language } = useTranslation();
const { network } = region;
if (!network) {
if (!region.imported_at || region.status === 'importing') return null;
return (
<Tooltip label={t('admin.paddleRegions.network.missingHint')}>
<span className="inline-flex">
<StatusPill tone="warning">{t('admin.paddleRegions.network.missing')}</StatusPill>
</span>
</Tooltip>
);
}
const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km);
const details = [
t('admin.paddleRegions.network.lakes', { count: network.lakes }),
t('admin.paddleRegions.network.portages', { count: network.portages }),
t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }),
t('admin.paddleRegions.network.locks', { count: network.locks }),
t('admin.paddleRegions.network.gaps', { count: network.gaps }),
].join(', ');
return (
<Tooltip label={details}>
<span className="inline-flex" data-testid="paddle-region-network">
<StatusPill>{t('admin.paddleRegions.network.km', { km })}</StatusPill>
</span>
</Tooltip>
);
}
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
const { t, language } = useTranslation();
const busy = admin.busyId === region.id;
@@ -239,10 +279,12 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
</span>
<span className="flex flex-wrap items-center gap-1.5">
<RegionStatus region={region} />
{region.origin === 'auto' && <AutoOriginPill />}
{region.imported_at && (
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
)}
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
<RegionNetwork region={region} />
</span>
{region.status === 'failed' && region.error && (
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
@@ -0,0 +1,50 @@
// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C).
import { act, renderHook, waitFor } from '@testing-library/react';
import { createElement, type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TranslationProvider } from '../../../i18n/TranslationContext';
const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad');
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
describe('usePaddleAutoLoad', () => {
beforeEach(() => {
vi.clearAllMocks();
repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 });
repo.saveSettings.mockImplementation(async (settings: unknown) => settings);
});
it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => {
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }));
renderHook(() => usePaddleAutoLoad(true, true), { wrapper });
expect(repo.settings).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => {
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).not.toBeNull());
await act(async () => {
await result.current.save({ mode: 'ask' });
});
expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 });
expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 });
});
it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => {
repo.saveSettings.mockRejectedValueOnce(new Error('nope'));
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).not.toBeNull());
await act(async () => {
await result.current.save({ max_mb: 2000 });
});
expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 });
expect(toast.error).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,59 @@
import type { PaddleRegionSettings } from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { paddleRegionRepo } from '../../../repo/paddleRegionRepo';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
/**
* PAD-11C in the admin area: how TREK loads the water data of a place a
* paddler plans in, read once the card shows and saved on every change. A
* change shows at once and goes back, with a toast, when the server refuses
* it. While one is saved the controls rest, so two saves never cross.
*/
export function usePaddleAutoLoad(active: boolean, offline: boolean) {
const { t } = useTranslation();
const toast = useToast();
const [settings, setSettings] = useState<PaddleRegionSettings | null>(null);
const [saving, setSaving] = useState(false);
const pending = useRef(false);
useEffect(() => {
if (!active || offline) return;
let cancelled = false;
paddleRegionRepo.settings().then(
(loaded) => {
if (!cancelled) setSettings(loaded);
},
(err: unknown) => {
if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error')));
}
);
return () => {
cancelled = true;
};
}, [active, offline, t, toast]);
const save = useCallback(
async (change: Partial<PaddleRegionSettings>) => {
if (!settings || pending.current) return;
const before = settings;
const next = { ...settings, ...change };
pending.current = true;
setSaving(true);
setSettings(next);
try {
setSettings(await paddleRegionRepo.saveSettings(next));
} catch (err) {
setSettings(before);
toast.error(getApiErrorMessage(err, t('common.error')));
} finally {
pending.current = false;
setSaving(false);
}
},
[settings, t, toast]
);
return { settings, saving, save };
}
@@ -24,11 +24,15 @@ const repo = vi.hoisted(() => ({
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
decline: vi.fn<(id: string) => Promise<{ success: true }>>(),
settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })),
saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
paddleRegionRepo: repo,
// The list answers its regions and no requests unless a test says otherwise (PAD-11C).
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
@@ -40,11 +44,13 @@ function region(id: string, status: PaddleRegion['status'], over: Partial<Paddle
id,
name: id,
status,
origin: 'admin',
source_timestamp: null,
imported_at: null,
feature_count: 0,
error: null,
progress: null,
network: null,
...over,
};
}
@@ -1,4 +1,4 @@
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { useTranslation } from '../../../i18n';
@@ -7,6 +7,7 @@ import { useAddonStore } from '../../../store/addonStore';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import { isReading } from './paddleRegionsModel';
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
/** 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;
@@ -23,12 +24,14 @@ function errorText(t: Translate, err: unknown): string {
}
/**
* 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
* The regions as the server holds them, with those paddlers asked for, 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 [requests, setRequests] = useState<PaddleRegionPending[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [asked, setAsked] = useState(0);
@@ -38,7 +41,8 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
paddleRegionRepo.list().then(
(list) => {
if (cancelled) return;
setRegions(list);
setRegions(list.regions);
setRequests(list.requests);
setError(null);
setLoaded(true);
},
@@ -59,7 +63,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
return () => window.clearInterval(timer);
}, [active, offline, reading]);
const reload = useCallback(() => setAsked((n) => n + 1), []);
return { regions, loading: !loaded && !offline, error, reload };
return { regions, requests, loading: !loaded && !offline, error, reload };
}
/** Geofabrik's list searched as the admin types, the last search winning. */
@@ -99,7 +103,8 @@ function useCatalogSearch(offline: boolean, t: Translate) {
* 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.
* a read follows without reloading the page. PAD-11C: how TREK loads regions
* for paddlers by itself, and the regions they asked for, to load or decline.
*/
export function usePaddleRegionsAdmin() {
const { t } = useTranslation();
@@ -107,6 +112,7 @@ export function usePaddleRegionsAdmin() {
const { offline } = useNetworkMode();
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
const list = useRegionList(toursOn, offline, t);
const autoLoad = usePaddleAutoLoad(toursOn, offline);
const search = useCatalogSearch(offline, t);
const [busyId, setBusyId] = useState<string | null>(null);
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
@@ -146,6 +152,12 @@ export function usePaddleRegionsAdmin() {
[run, setQuery, t]
);
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
/** A paddler's request let through: the region is added like any other. */
const approve = useCallback(
(id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')),
[run, t]
);
const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]);
const confirmRemove = useCallback(() => {
const region = removing;
setRemoving(null);
@@ -157,6 +169,8 @@ export function usePaddleRegionsAdmin() {
toursOn,
offline,
regions: list.regions,
requests: list.requests,
autoLoad,
loading: list.loading,
error: list.error,
retry: reload,
@@ -165,6 +179,8 @@ export function usePaddleRegionsAdmin() {
busyId,
add,
refresh,
approve,
decline,
removing,
askRemove: setRemoving,
cancelRemove: () => setRemoving(null),
@@ -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'] },
];
/**
@@ -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 />}
@@ -1,4 +1,4 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-017
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-020
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
@@ -59,6 +59,38 @@ describe('MapLayerSwitcher', () => {
expect(onChange).toHaveBeenCalledWith('topo')
})
it('FE-COMP-MAPLAYER-018: offers the hiking map only where the tour type and the admin offer it (MAP-01)', () => {
const onChange = vi.fn()
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={onChange} layers={{ bases: ['default', 'topo', 'satellite'] }} />)
expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull()
rerender(<TourMapLayerSwitcher active="default" onChange={onChange} layers={{ bases: ['default', 'topo', 'hiking', 'satellite'] }} />)
fireEvent.click(screen.getByRole('button', { name: 'Hiking' }))
expect(onChange).toHaveBeenCalledWith('hiking')
})
it('FE-COMP-MAPLAYER-019: a hiking map that stopped answering says so on its segment, and only while it is shown', () => {
const baseHealth = { base: 'hiking' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
const layers = { bases: ['default', 'hiking'] as const, baseHealth }
const { rerender } = render(<TourMapLayerSwitcher active="hiking" onChange={() => {}} layers={layers} />)
const segment = screen.getByRole('button', { name: 'Hiking' })
expect(segment).toHaveAttribute('aria-pressed', 'true')
expect(segment).toHaveTextContent('!')
expect(screen.getByRole('status')).toHaveTextContent('OpenHikingMap is not answering right now')
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={layers} />)
expect(screen.getByRole('button', { name: 'Hiking' })).not.toHaveTextContent('!')
expect(screen.queryByRole('status')).toBeNull()
})
it('FE-COMP-MAPLAYER-020: CyclOSM sits in the switch as Cycling and says so when it stops answering (BIK-06)', () => {
const baseHealth = { base: 'cycle' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
render(<TourMapLayerSwitcher active="cycle" onChange={() => {}} layers={{ bases: ['default', 'cycle'], baseHealth }} />)
expect(screen.getByRole('button', { name: 'Cycling' })).toHaveTextContent('!')
expect(screen.getByRole('status')).toHaveTextContent('CyclOSM is not answering right now')
expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull()
})
it('FE-COMP-MAPLAYER-008: the shell is as wide as the size its neighbours are placed off', () => {
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
const button = screen.getByRole('button')
+28 -8
View File
@@ -1,4 +1,4 @@
import { Anchor, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
import { Anchor, Bike, Footprints, 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'
@@ -9,9 +9,10 @@ import { fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
export type BaseLayer = 'default' | 'satellite'
export type TourBaseLayer = BaseLayer | 'topo'
export type TourBaseLayer = BaseLayer | TourRasterBase
/** What a tour's type offers on the planner map (P2B-06): its base layers, and the overlays beside the ferratas. */
export interface TourLayerOffer {
@@ -19,6 +20,8 @@ export interface TourLayerOffer {
overlays?: readonly ToggleableRasterOverlay[]
/** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */
paddle?: PaddleMapLayer
/** MAP-01, BIK-06: whether the map on terms shown (OpenHikingMap, CyclOSM) answers; the maps report into it. */
baseHealth?: BaseMapHealth
}
const FROSTED_SHELL = {
@@ -83,6 +86,8 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
const TOUR_LAYERS = [
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
{ id: 'topo', labelKey: null, Icon: Mountain },
{ id: 'hiking', labelKey: 'map.baseLayer.hiking', Icon: Footprints },
{ id: 'cycle', labelKey: 'map.baseLayer.cycle', Icon: Bike },
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
] as const
@@ -199,6 +204,15 @@ const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; down
* once its source stopped answering, which the tooltip names. The map and the
* planning go on without it.
*/
/** The warning mark on a switch whose source stopped answering; the tooltip says what it means. */
function DownBadge() {
return (
<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>
)
}
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
const { t } = useTranslation()
const look = OVERLAY_LOOK[overlay.id]
@@ -221,11 +235,7 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
>
<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>
)}
{down && <DownBadge />}
</button>
</Tooltip>
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
@@ -265,14 +275,18 @@ export function TourMapLayerSwitcher({
const { t } = useTranslation()
const bases = layers?.bases
const overlays = layers?.overlays
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
const health = layers?.baseHealth
return (
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
const selected = active === id
const label = labelKey ? t(labelKey) : 'Topo'
const down = !!health?.down && health.base === id && active === id
const downLabel = id === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')
return (
<Tooltip key={id} label={label} placement="top" disabled={!compact}>
<Tooltip key={id} label={down ? downLabel : label} placement="top" disabled={!compact && !down}>
<button
type="button"
onClick={() => onChange(id)}
@@ -283,10 +297,16 @@ export function TourMapLayerSwitcher({
>
<Icon size={15} strokeWidth={2} />
{!compact && <span>{label}</span>}
{down && <DownBadge />}
</button>
</Tooltip>
)
})}
{health?.down && active === health.base && (
<span role="status" aria-live="polite" className="sr-only">
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
</span>
)}
{(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} />}
+6 -1
View File
@@ -252,7 +252,7 @@ describe('MapView', () => {
const route = [[[39.9042, 116.4074], [39.9052, 116.4084]]] as [number, number][][]
const plannerWaypoints = [{ id: 'start', lat: 39.9042, lng: 116.4074, role: 'start' as const }]
const routeProfileFocus = { distanceMeters: 100, elevationMeters: 0, lat: 39.9047, lng: 116.4079, sampleIndex: 0 }
const props = (viewBaseLayer: 'default' | 'topo' | 'satellite') => ({
const props = (viewBaseLayer: 'default' | 'topo' | 'hiking' | 'satellite') => ({
places: [beijing], center: [39.9042, 116.4074] as [number, number], zoom: 15,
route, plannerWaypoints, routeProfileFocus, tileUrl: AMAP_ROAD, viewBaseLayer, onViewBaseLayerChange,
})
@@ -279,6 +279,11 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').every(polyline => polyline.getAttribute('data-points') === JSON.stringify(route[0]))).toBe(true)
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', JSON.stringify([routeProfileFocus.lat, routeProfileFocus.lng]))
// MAP-01: OpenHikingMap is drawn in WGS-84 like topo, from its own template.
rerender(<MapView {...props('hiking')} />)
expect(screen.getByTestId('map-container')).toHaveAttribute('data-crs', 'EPSG:3857')
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', 'https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png')
rerender(<MapView {...props('satellite')} />)
const satelliteMap = screen.getByTestId('map-container')
expect(satelliteMap).not.toBe(topoMap)
+15 -22
View File
@@ -8,6 +8,7 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
import HazardLayers from './HazardLayers'
import { RouteHitBand } from './RouteHitBand'
import { formatViaDwell } from './routeViaDwell'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
@@ -62,7 +63,8 @@ import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './p
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 { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
@@ -838,7 +840,7 @@ export const MapView = memo(function MapView({
const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
const isSatellite = baseLayer === 'satellite'
const isTopo = baseLayer === 'topo'
const tourRaster = tourRasterBaseOf(baseLayer) // topo, OpenHikingMap or CyclOSM, the Tours planner's own
// The caller hands over whatever the user configured; what kind of basemap
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
@@ -850,7 +852,7 @@ export const MapView = memo(function MapView({
* decided before the map is constructed, because Leaflet cannot change a map's
* CRS afterwards — hence the whole map, not just the tile layer.
*/
const isGcjBasemap = !isTopo && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
@@ -1115,17 +1117,16 @@ export const MapView = memo(function MapView({
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
) : isTopo ? (
) : tourRaster ? (
<TileLayer
key="topo"
url={OPENTOPOMAP_TILE_URL}
attribution={OPENTOPOMAP_TILE_ATTRIBUTION}
maxNativeZoom={OPENTOPOMAP_TILE_MAXZOOM}
key={baseLayer}
url={tourRaster.url}
attribution={tourRaster.attribution}
maxNativeZoom={tourRaster.maxZoom}
maxZoom={MAP_MAX_ZOOM}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
keepBuffer={8} updateWhenZooming={false} updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
eventHandlers={tileHealthHandlers(tourLayers?.baseHealth, baseLayer)}
/>
) : basemap.kind === 'vector' ? (
<VectorBasemap style={basemap.style} />
@@ -1158,8 +1159,7 @@ export const MapView = memo(function MapView({
</MarkerClusterGroup>
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on. */}
When the route can be reshaped an invisible band wider than both takes the click. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline
@@ -1173,15 +1173,7 @@ export const MapView = memo(function MapView({
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu
// underneath the new via.
e.originalEvent.stopPropagation()
onRouteClick(e.latlng.lat, e.latlng.lng)
},
} : undefined}
interactive={false}
/>,
<Polyline
key={`${i}-core`}
@@ -1189,6 +1181,7 @@ export const MapView = memo(function MapView({
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
...(onRouteClick ? [<RouteHitBand key={`${i}-hit`} positions={seg} onRouteClick={onRouteClick} />] : []),
])}
{/* The last bit to a place the road does not reach.
+4 -7
View File
@@ -8,7 +8,7 @@ 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 { applyBaseLayer } from './glRasterLayers'
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
@@ -612,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)
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays, tourLayers?.paddle)
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers)
// 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
@@ -2185,14 +2185,11 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const apply = () => {
applySatellite(map, isSatellite)
applySatellite(map, baseLayer === 'topo', true)
}
const apply = () => applyBaseLayer(map, baseLayer)
apply()
map.on('styledata', apply)
return () => { map.off('styledata', apply) }
}, [isSatellite, baseLayer, mapReady, glProvider])
}, [baseLayer, mapReady, glProvider])
if (!isMapLibre && !mapboxToken) {
return (
@@ -2,18 +2,25 @@ import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The pin's inline style for a size and selection; the renderer owns the colour. */
export type PinCss = (size: number, selected: boolean) => string;
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
const size = selected ? 30 : 26;
function plannerWaypointIcon(
pinCss: PinCss,
point: PlannerPinPoint,
index: number,
selected: boolean,
draggable: boolean
): L.DivIcon {
const { size, text } = plannerPinShape(point, index, selected);
return L.divIcon({
className: 'tour-planner-waypoint-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
});
}
@@ -25,7 +32,7 @@ export default function PlannerWaypointMarker({
onMove,
pinCss,
}: {
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint;
index: number;
selected: boolean;
onClick?: (id: string) => void;
@@ -39,7 +46,11 @@ export default function PlannerWaypointMarker({
handlersRef.current = { onClick, onMove };
}, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
const { label, shaping } = point;
const icon = useMemo(
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
[index, label, onMove, pinCss, selected, shaping]
);
const eventHandlers = useMemo(
() => ({
click: (event: { originalEvent: MouseEvent }) => {
@@ -0,0 +1,35 @@
import { Polyline } from 'react-leaflet';
/**
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
* than the drawn line, that takes the click which reshapes the route, as the
* GL renderer's hit layer does: an 8 px line is too thin to aim at.
*/
export function RouteHitBand({
positions,
onRouteClick,
}: {
positions: [number, number][];
onRouteClick: (lat: number, lng: number) => void;
}) {
return (
<Polyline
positions={positions}
pathOptions={{
color: 'transparent',
weight: 20,
opacity: 0,
lineCap: 'round',
lineJoin: 'round',
className: 'cursor-copy',
}}
eventHandlers={{
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
e.originalEvent.stopPropagation();
onRouteClick(e.latlng.lat, e.latlng.lng);
},
}}
/>
);
}
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { applyBaseLayer, tourRasterSourceId } from './glRasterLayers';
/** Enough of a GL map for the base layers: sources, layers with their visibility, and the style's order. */
function fakeMap(styleLayers: Array<{ id: string }> = [{ id: 'water' }, { id: 'trip-route' }]) {
const sources = new Map<string, Record<string, unknown>>();
const layers = new Map<string, string>();
const map = {
getSource: vi.fn((id: string) => sources.get(id)),
addSource: vi.fn((id: string, source: Record<string, unknown>) => sources.set(id, source)),
getLayer: vi.fn((id: string) => (layers.has(id) ? { id } : undefined)),
addLayer: vi.fn((layer: { id: string }, _before?: string) => layers.set(layer.id, 'visible')),
setLayoutProperty: vi.fn((id: string, _key: string, value: string) => layers.set(id, value)),
getStyle: vi.fn(() => ({ layers: styleLayers })),
};
return { map, sources, layers };
}
describe('applyBaseLayer (MAP-01)', () => {
it('lays OpenHikingMap with its credit under the first thing TREK draws', () => {
const { map, sources } = fakeMap();
applyBaseLayer(map, 'hiking');
expect(tourRasterSourceId('hiking')).toBe('trip-hiking');
expect(sources.get('trip-hiking')).toMatchObject({
type: 'raster',
tiles: ['https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png'],
maxzoom: 18,
});
expect(String(sources.get('trip-hiking')?.attribution)).toContain('openmaps.fr/donate');
expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-hiking-raster' }), 'trip-route');
// Nothing else is built while it is off.
expect(sources.has('trip-topo')).toBe(false);
expect(sources.has('trip-satellite')).toBe(false);
});
it('switching away hides it, and back shows the same layer again without a second source', () => {
const { map, layers } = fakeMap();
applyBaseLayer(map, 'hiking');
applyBaseLayer(map, 'topo');
expect(layers.get('trip-hiking-raster')).toBe('none');
expect(layers.get('trip-topo-raster')).toBe('visible');
applyBaseLayer(map, 'hiking');
expect(layers.get('trip-hiking-raster')).toBe('visible');
expect(layers.get('trip-topo-raster')).toBe('none');
expect(map.addSource).toHaveBeenCalledTimes(2);
});
it("keeps the user's own map when the style refuses the layer", () => {
const { map } = fakeMap();
map.addLayer.mockImplementation(() => {
throw new Error('style not loaded');
});
expect(() => applyBaseLayer(map, 'hiking')).not.toThrow();
});
});
+21 -17
View File
@@ -1,15 +1,9 @@
import {
OPENTOPOMAP_TILE_ATTRIBUTION,
OPENTOPOMAP_TILE_MAXZOOM,
OPENTOPOMAP_TILE_URL,
SATELLITE_TILE_ATTRIBUTION,
SATELLITE_TILE_MAXZOOM,
SATELLITE_TILE_URL,
} from '../../constants/mapDefaults';
import { SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, SATELLITE_TILE_URL } from '../../constants/mapDefaults';
import { TOUR_RASTER_BASE_IDS, TOUR_RASTER_BASES, type TourRasterBase } from './tourRasterBases';
/*
* 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)
* adds on top: the satellite, topo and hiking base layers, and (in mapRasterOverlay.ts)
* the transparent overlays such as the seamarks.
*/
@@ -50,9 +44,10 @@ export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
* 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;
export function applySatellite(map: any, on: boolean, raster?: TourRasterBase): void {
const layerId = raster ? `trip-${raster}-raster` : SATELLITE_LAYER_ID;
const sourceId = raster ? tourRasterSourceId(raster) : SATELLITE_SOURCE_ID;
const source = raster ? TOUR_RASTER_BASES[raster] : null;
try {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
@@ -65,12 +60,10 @@ export function applySatellite(map: any, on: boolean, topo = false): void {
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],
tiles: source ? source.tiles : [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
maxzoom: source ? source.maxZoom : SATELLITE_TILE_MAXZOOM,
attribution: source ? source.attribution : SATELLITE_TILE_ATTRIBUTION,
});
}
// Under the first thing TREK draws, over everything the basemap style draws.
@@ -90,3 +83,14 @@ export function applySatellite(map: any, on: boolean, topo = false): void {
/* a style that refuses the layer keeps the plain basemap */
}
}
/** The GL source a Tours raster base map is drawn from, which its tile events name. */
export function tourRasterSourceId(raster: TourRasterBase): string {
return `trip-${raster}`;
}
/** The base layer on a GL map: the imagery, a Tours raster map, or neither over the user's own style. */
export function applyBaseLayer(map: Parameters<typeof applySatellite>[0], baseLayer: string): void {
applySatellite(map, baseLayer === 'satellite');
for (const raster of TOUR_RASTER_BASE_IDS) applySatellite(map, baseLayer === raster, raster);
}
@@ -1,6 +1,11 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useRasterOverlaysGL, type MapRasterOverlay, type RasterOverlayGlMap } from './mapRasterOverlay';
import {
useRasterOverlaysGL,
useSourceTileHealthGL,
type MapRasterOverlay,
type RasterOverlayGlMap,
} from './mapRasterOverlay';
type Handler = (event: Record<string, unknown>) => void;
@@ -103,3 +108,27 @@ describe('useRasterOverlaysGL', () => {
expect(map.on).not.toHaveBeenCalled();
});
});
describe('useSourceTileHealthGL (MAP-01)', () => {
it("reports one source's failed and arrived tiles into its health, nothing else's", () => {
const { gl, fire } = fakeMap();
const health = { down: false, onTileError: vi.fn(), onTileLoad: vi.fn() };
renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', health));
fire('error', { sourceId: 'trip-hiking', error: new Error('503') });
fire('error', { sourceId: 'trip-topo', error: new Error('503') });
fire('sourcedata', { sourceId: 'trip-hiking', tile: {} });
fire('sourcedata', { sourceId: 'trip-hiking' });
expect(health.onTileError).toHaveBeenCalledTimes(1);
expect(health.onTileLoad).toHaveBeenCalledTimes(1);
});
it('listens to nothing while the map is not shown, and lets go when it goes', () => {
const { map, gl } = fakeMap();
const health = { down: false, onTileError: vi.fn(), onTileLoad: vi.fn() };
renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', undefined));
expect(map.on).not.toHaveBeenCalled();
const { unmount } = renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', health));
unmount();
expect(map.off).toHaveBeenCalledTimes(2);
});
});
+38 -6
View File
@@ -4,7 +4,8 @@ import { isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerG
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';
import { OWN_LAYER_PREFIXES, tourRasterSourceId } from './glRasterLayers';
import type { BaseMapHealth, TileHealth } from './tourRasterBases';
/**
* A transparent raster layer laid over the basemap and under the route, such
@@ -104,16 +105,47 @@ export function useRasterOverlaysGL(map: RasterOverlayGlMap | null, overlays: re
}, [map, overlays]);
}
/** The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */
/**
* Reports the tiles of one GL source into its health while `health` is given:
* a failure carries the source it came from, an arrival a tile. The base layer
* itself is drawn by applyBaseLayer.
*/
export function useSourceTileHealthGL(
map: RasterOverlayGlMap | null,
sourceId: string,
health: TileHealth | undefined
) {
const { onTileError, onTileLoad } = health ?? {};
useEffect(() => {
if (!map || !onTileError || !onTileLoad) return;
const failed = (event: { error?: Error; sourceId?: string }) => {
if (event.sourceId === sourceId) onTileError();
};
const loaded = (event: { sourceId?: string; tile?: unknown }) => {
if (event.sourceId === sourceId && event.tile) onTileLoad();
};
map.on('error', failed);
map.on('sourcedata', loaded);
return () => {
map.off('error', failed);
map.off('sourcedata', loaded);
};
}, [map, sourceId, onTileError, onTileLoad]);
}
/**
* The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers,
* the paddle obstacles, and whether the map on terms (OpenHikingMap, CyclOSM) answers while it is the base map.
*/
export function useTourMapLayersGL(
map: RasterOverlayGlMap | null,
ferrataLayer: FerrataMapLayer | undefined,
overlays: readonly MapRasterOverlay[] | undefined,
paddle?: PaddleMapLayer
layers: { overlays?: readonly MapRasterOverlay[]; paddle?: PaddleMapLayer; baseHealth?: BaseMapHealth } | undefined
) {
useFerrataMapLayerGL(map, ferrataLayer);
useRasterOverlaysGL(map, overlays);
usePaddleLayerGL(map as unknown as PaddleGlMap | null, paddle);
useRasterOverlaysGL(map, layers?.overlays);
usePaddleLayerGL(map as unknown as PaddleGlMap | null, layers?.paddle);
useSourceTileHealthGL(map, tourRasterSourceId(layers?.baseHealth?.base ?? 'hiking'), layers?.baseHealth);
}
/**
+9 -1
View File
@@ -110,7 +110,15 @@ export interface MapViewProps {
onHighlightAlternative?: (index: number | null) => void;
/** Generic numbered control points for list-first route editors. */
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
plannerWaypoints?: Array<{
id: string;
lat: number;
lng: number;
name?: string;
detail?: string;
label?: string;
shaping?: boolean;
}>;
selectedPlannerWaypointId?: string | null;
onPlannerWaypointClick?: (id: string) => void;
/** Drops a dragged control point; returns false when the move was refused. */
@@ -0,0 +1,12 @@
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
import { describe, expect, it } from 'vitest';
import { plannerPinShape } from './plannerPinShape';
describe('plannerPinShape', () => {
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' });
expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' });
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
});
});
@@ -0,0 +1,16 @@
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
export interface PlannerPinPoint {
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
label?: string;
shaping?: boolean;
}
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
export function plannerPinShape(
point: PlannerPinPoint,
index: number,
selected: boolean
): { size: number; text: string } {
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
}
+30 -3
View File
@@ -1,4 +1,5 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, type LucideIcon } from 'lucide-react'
import type { TourPoiCategory } from '@trek/shared'
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
// The road-trip kinds take their colour from the one place that defines it, so the
// search result, the map pin and the road-trip rail cannot drift apart.
@@ -40,9 +41,35 @@ export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
)
const CORE_COLOR = new Map(POI_CATEGORIES.map(c => [c.key, c.color] as const))
const coreColor = (key: string) => CORE_COLOR.get(key) ?? POI_DEFAULT_COLOR
/**
* What a hike, a ride or a paddle tour stops for (PLN-02), searched along a tour in the
* planner and drawn on its map. Each takes the colour of the explore category
* it is closest to, so the map keeps one palette.
*/
export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
viewpoint: { key: 'viewpoint', labelKey: 'tours.poi.cat.viewpoint', Icon: Binoculars, color: coreColor('sights') },
peak: { key: 'peak', labelKey: 'tours.poi.cat.peak', Icon: Mountain, color: coreColor('nature') },
shelter: { key: 'shelter', labelKey: 'tours.poi.cat.shelter', Icon: Warehouse, color: HOTEL_COLOR },
drinking_water: { key: 'drinking_water', labelKey: 'tours.poi.cat.drinking_water', Icon: Droplet, color: coreColor('museum') },
food: { key: 'food', labelKey: 'tours.poi.cat.food', Icon: Utensils, color: coreColor('restaurant') },
toilets: { key: 'toilets', labelKey: 'tours.poi.cat.toilets', Icon: Toilet, color: coreColor('bar') },
picnic: { key: 'picnic', labelKey: 'tours.poi.cat.picnic', Icon: Sandwich, color: coreColor('nature') },
campsite: { key: 'campsite', labelKey: 'tours.poi.cat.campsite', Icon: Tent, color: coreColor('activity') },
sights: { key: 'sights', labelKey: 'tours.poi.cat.sights', Icon: Camera, color: coreColor('sights') },
slipway: { key: 'slipway', labelKey: 'tours.poi.cat.slipway', Icon: Anchor, color: coreColor('museum') },
boat_rental: { key: 'boat_rental', labelKey: 'tours.poi.cat.boat_rental', Icon: Sailboat, color: coreColor('cafe') },
bike_repair: { key: 'bike_repair', labelKey: 'tours.poi.cat.bike_repair', Icon: Wrench, color: coreColor('activity') },
}
// A Map rather than an object, so a category named after an Object.prototype member
// (`constructor`, `toString`) finds nothing rather than a function.
const CATEGORY_BY_KEY = new Map([...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c] as const))
// (`constructor`, `toString`) finds nothing rather than a function. A Tours category
// that shares a key with an explore one (`sights`, `campsite`) keeps the explore look.
const CATEGORY_BY_KEY = new Map(
[...Object.values(TOUR_POI_LOOKS), ...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c] as const),
)
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
export interface Poi {
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import { isOnTermsBase, tileHealthHandlers, TOUR_RASTER_BASES, tourRasterBaseOf } from './tourRasterBases';
// FE-MAP-TOURBASES-001 to FE-MAP-TOURBASES-003
describe('tourRasterBases (MAP-01, BIK-06)', () => {
it('FE-MAP-TOURBASES-001: knows topo, OpenHikingMap and CyclOSM, CyclOSM on its three shards', () => {
expect(tourRasterBaseOf('topo')).toBe(TOUR_RASTER_BASES.topo);
expect(tourRasterBaseOf('cycle')?.tiles).toEqual([
'https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
'https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
'https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
]);
expect(tourRasterBaseOf('cycle')?.maxZoom).toBe(20);
expect(tourRasterBaseOf('satellite')).toBeNull();
// Inherited names are no base map.
expect(tourRasterBaseOf('toString')).toBeNull();
});
it('FE-MAP-TOURBASES-002: the maps on terms carry their credit; topo is no map on terms', () => {
expect(isOnTermsBase('hiking')).toBe(true);
expect(isOnTermsBase('cycle')).toBe(true);
expect(isOnTermsBase('topo')).toBe(false);
expect(isOnTermsBase('default')).toBe(false);
expect(TOUR_RASTER_BASES.cycle.onTerms?.credit.map((part) => part.href)).toEqual([
'https://www.cyclosm.org',
'https://www.openstreetmap.org/copyright',
]);
expect(TOUR_RASTER_BASES.hiking.onTerms?.credit).toHaveLength(3);
});
it('FE-MAP-TOURBASES-003: a Leaflet layer reports into the health of the map watched, and only of that one', () => {
const health = { base: 'cycle' as const, down: false, onTileError: vi.fn(), onTileLoad: vi.fn() };
expect(tileHealthHandlers(health, 'cycle')).toEqual({ tileerror: health.onTileError, tileload: health.onTileLoad });
expect(tileHealthHandlers(health, 'hiking')).toBeUndefined();
expect(tileHealthHandlers(undefined, 'cycle')).toBeUndefined();
});
});
@@ -0,0 +1,98 @@
import {
CYCLOSM_TILE_ATTRIBUTION,
CYCLOSM_TILE_MAXZOOM,
CYCLOSM_TILE_URL,
OPENHIKINGMAP_TILE_ATTRIBUTION,
OPENHIKINGMAP_TILE_MAXZOOM,
OPENHIKINGMAP_TILE_URL,
OPENTOPOMAP_TILE_ATTRIBUTION,
OPENTOPOMAP_TILE_MAXZOOM,
OPENTOPOMAP_TILE_URL,
} from '../../constants/mapDefaults';
/** The raster base maps only the Tours planner offers, drawn the same way by Leaflet, GL and the 3D map. */
export type TourRasterBase = 'topo' | 'hiking' | 'cycle';
export interface TourRasterBaseSource {
/** The Leaflet template; `{s}` is a shard Leaflet fills in. */
url: string;
/** The same tiles spelled out for a GL source, which knows no shards. */
tiles: string[];
attribution: string;
maxZoom: number;
/**
* A map its operator offers on terms (MAP-01, BIK-06): loaded only as viewed, never
* prefetched or kept offline, its credit readable without a click, and watched for whether it
* answers. `credit` is what the 3D map shows above its controls.
*/
onTerms?: { credit: ReadonlyArray<{ label: string; href: string }> };
}
export const TOUR_RASTER_BASES: Record<TourRasterBase, TourRasterBaseSource> = {
topo: {
url: OPENTOPOMAP_TILE_URL,
tiles: ['a', 'b', 'c'].map((shard) => OPENTOPOMAP_TILE_URL.replace('{s}', shard)),
attribution: OPENTOPOMAP_TILE_ATTRIBUTION,
maxZoom: OPENTOPOMAP_TILE_MAXZOOM,
},
hiking: {
url: OPENHIKINGMAP_TILE_URL,
tiles: [OPENHIKINGMAP_TILE_URL],
attribution: OPENHIKINGMAP_TILE_ATTRIBUTION,
maxZoom: OPENHIKINGMAP_TILE_MAXZOOM,
onTerms: {
credit: [
{ label: '\u00a9 OpenHikingMap', href: 'https://wiki.openstreetmap.org/wiki/OpenHikingMap' },
{ label: '\u00a9 OpenStreetMap', href: 'https://www.openstreetmap.org/copyright' },
{ label: '\u2764\ufe0f Donation', href: 'https://openmaps.fr/donate' },
],
},
},
cycle: {
url: CYCLOSM_TILE_URL,
tiles: ['a', 'b', 'c'].map((shard) => CYCLOSM_TILE_URL.replace('{s}', shard)),
attribution: CYCLOSM_TILE_ATTRIBUTION,
maxZoom: CYCLOSM_TILE_MAXZOOM,
onTerms: {
credit: [
{ label: '\u00a9 CyclOSM', href: 'https://www.cyclosm.org' },
{ label: '\u00a9 OpenStreetMap', href: 'https://www.openstreetmap.org/copyright' },
],
},
},
};
export const TOUR_RASTER_BASE_IDS = Object.keys(TOUR_RASTER_BASES) as TourRasterBase[];
/** The raster source of a base layer, or null for the user's own map and the imagery. */
export function tourRasterBaseOf(layer: string): TourRasterBaseSource | null {
return Object.prototype.hasOwnProperty.call(TOUR_RASTER_BASES, layer)
? TOUR_RASTER_BASES[layer as TourRasterBase]
: null;
}
/** Whether the layer is a map its operator offers on terms (OpenHikingMap, CyclOSM). */
export function isOnTermsBase(layer: string): layer is TourRasterBase {
return !!tourRasterBaseOf(layer)?.onTerms;
}
/**
* Whether a best-effort tile source still answers: a run of failed tiles marks
* it down, one tile arriving clears that. The maps report into it, the switch
* shows it.
*/
export interface TileHealth {
down: boolean;
onTileError: () => void;
onTileLoad: () => void;
}
/** The health of the map on terms that is the base map now, with which one it is. */
export interface BaseMapHealth extends TileHealth {
base: TourRasterBase;
}
/** A Leaflet tile layer's events that report into the health of `layer`, while it is the map watched. */
export function tileHealthHandlers(health: BaseMapHealth | undefined, layer: string) {
return health?.base === layer ? { tileerror: health.onTileError, tileload: health.onTileLoad } : undefined;
}
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { plannerPinShape } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
@@ -71,10 +72,10 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId;
const size = selected ? 30 : 26;
const { size, text } = plannerPinShape(point, index, selected);
const el = document.createElement('button');
el.type = 'button';
el.textContent = String(index + 1);
el.textContent = text;
const name = point.name ?? `Waypoint ${index + 1}`;
el.setAttribute('aria-label', name);
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore'
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids, valhallaTraceAttributes } from './valhallaRoute'
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
@@ -531,3 +531,55 @@ describe('valhallaAlternates', () => {
expect(calls).toBe(2)
})
})
describe('valhallaTraceAttributes (BIK-01)', () => {
const TRACE = 'https://valhalla1.openstreetmap.de/trace_attributes'
it('VALHALLA-TRACE-001: asks for the ways under the line, matched as a mountain bike rides, and hands the answer on', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(TRACE, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ shape: SHAPE, edges: [] })
}))
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toEqual({ shape: SHAPE, edges: [] })
expect(bodies[0]).toMatchObject({
costing: 'bicycle',
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
shape_match: 'map_snap',
filters: { action: 'include' },
})
expect(bodies[0].shape).toEqual(SHAPE_POINTS.map(([lat, lon]) => ({ lat, lon })))
})
it('VALHALLA-TRACE-002: a long line is thinned evenly to what one request carries, its ends kept', async () => {
let shape: unknown[] = []
server.use(http.post(TRACE, async ({ request }) => {
shape = ((await request.json()) as { shape: unknown[] }).shape
return HttpResponse.json({})
}))
const line = Array.from({ length: 9001 }, (_, i): [number, number] => [47 + i / 1e5, 11])
await valhallaTraceAttributes(line)
expect(shape.length).toBeLessThanOrEqual(4001)
expect(shape[0]).toEqual({ lat: 47, lon: 11 })
expect(shape[shape.length - 1]).toEqual({ lat: line[9000][0], lon: 11 })
})
it('VALHALLA-TRACE-003: null for a refusal, a network failure, a line of one point or a router that is not Valhalla', async () => {
server.use(http.post(TRACE, () => new HttpResponse(null, { status: 400 })))
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
server.use(http.post(TRACE, () => HttpResponse.error()))
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
await expect(valhallaTraceAttributes(SHAPE_POINTS.slice(0, 1))).resolves.toBeNull()
setSettings({ routing_base_url: 'https://osrm.example' })
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
})
it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => {
let asked = false
server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) }))
const controller = new AbortController()
controller.abort()
await expect(valhallaTraceAttributes(SHAPE_POINTS, controller.signal)).resolves.toBeNull()
expect(asked).toBe(false)
})
})
+52 -4
View File
@@ -55,6 +55,9 @@ const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
cycling: 'bicycle',
}
/** What a caller tunes in the costing model: a weighting, a switch, or a bicycle type by name. */
export type CostingOptions = Record<string, number | string | boolean>
/**
* What a caller can ask to be left out, against the costing option that asks for it.
*
@@ -191,7 +194,7 @@ export async function valhallaRun(
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
costingOptions: Readonly<Record<string, number>> = {},
costingOptions: Readonly<CostingOptions> = {},
): Promise<ValhallaRun | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
@@ -315,14 +318,14 @@ function routeBody(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
costingOptions: Readonly<Record<string, number>> = {},
costingOptions: Readonly<CostingOptions> = {},
): Record<string, unknown> {
const costing = COSTING[profile]
// All of them in one request: the options are independent weightings, so asking to
// leave out tolls and ferries together is one question rather than two routes to
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
// away together answered 656.9 km against 339.2 km for the plain route.
const options: Record<string, number> = { ...costingOptions }
const options: CostingOptions = { ...costingOptions }
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
return {
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
@@ -359,6 +362,51 @@ async function postRoute(base: string, body: Record<string, unknown>, signal?: A
}
}
/** The most points a description of a line asks about; a longer line is thinned evenly, its ends kept. */
const TRACE_MAX_POINTS = 4000
/** How long a description may take before it counts as failed, so a router that hangs does not leave its card waiting. */
const TRACE_TIMEOUT_MS = 30_000
/**
* BIK-01: how the router describes the ways under a line (`trace_attributes`):
* the surface, the use and road class, the cycle lane and the OpenStreetMap way
* of every edge, with the line as matched. The edges are matched as a mountain
* bike would ride them, the costing that refuses the fewest ways, so a road
* bike's route and a gravel one are described alike. Null for every failure:
* the description is an addition beside a route that already exists.
*/
export async function valhallaTraceAttributes(line: readonly [number, number][], signal?: AbortSignal): Promise<unknown> {
const base = valhallaBase()
if (!base || line.length < 2) return null
const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS))
const shape = line.filter((_, index) => index % step === 0 || index === line.length - 1).map(([lat, lon]) => ({ lat, lon }))
const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(TRACE_TIMEOUT_MS)]) : AbortSignal.timeout(TRACE_TIMEOUT_MS)
try {
await valhallaTurn(base, signal)
if (signal?.aborted) return null
const response = await fetch(`${base}/trace_attributes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
body: JSON.stringify({
shape,
costing: 'bicycle',
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
shape_match: 'map_snap',
filters: {
attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length', 'edge.way_id',
'edge.begin_shape_index', 'edge.end_shape_index'],
action: 'include',
},
}),
signal: bounded,
})
if (!response.ok) return null
return await response.json()
} catch {
return null
}
}
/** One request, at most ten locations. */
async function requestRun(
base: string,
@@ -366,7 +414,7 @@ async function requestRun(
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
costingOptions: Readonly<Record<string, number>> = {},
costingOptions: Readonly<CostingOptions> = {},
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
return data === null ? null : runFrom(data)
@@ -159,7 +159,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
)
return (
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" align="top" blocked={!!asking} header={header} footer={footer}>
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" blocked={!!asking} header={header} footer={footer}>
<div ref={bodyRef}>
{asking ? (
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
+9 -4
View File
@@ -1,4 +1,4 @@
import type { MouseEvent, ReactNode } from 'react'
import type { MouseEvent, ReactNode, Ref } from 'react'
import { Clock, MoreHorizontal, type LucideIcon } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
@@ -15,8 +15,11 @@ import { fs } from '../shared/DialogShell'
export const PANEL_BAR = 'flex min-h-[52px] flex-none items-center gap-1.5 border-b border-edge-faint px-3 py-2'
/** A 32px icon control on a panel bar. Raised like an active filter tab while `active`. */
export function BarButton({ label, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom' }: {
export function BarButton({ label, tooltip, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom', testId, ref }: {
label: string
/** A longer explanation for the tooltip; the label still names the button. */
tooltip?: string
testId?: string
onClick: (e: MouseEvent<HTMLButtonElement>) => void
active?: boolean
disabled?: boolean
@@ -26,10 +29,12 @@ export function BarButton({ label, onClick, active = false, disabled = false, ch
ariaExpanded?: boolean
ariaHaspopup?: 'dialog' | 'menu'
placement?: 'top' | 'bottom' | 'left' | 'right'
/** The button itself, for a list or a menu that opens beside it. */
ref?: Ref<HTMLButtonElement>
}) {
return (
<Tooltip label={label} placement={placement}>
<button type="button" onClick={onClick} disabled={disabled} aria-label={label}
<Tooltip label={tooltip ?? label} placement={placement}>
<button ref={ref} type="button" onClick={onClick} disabled={disabled} aria-label={label} data-testid={testId}
aria-pressed={ariaPressed} aria-expanded={ariaExpanded} aria-haspopup={ariaHaspopup}
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted enabled:hover:bg-surface-card enabled:hover:text-content'} ${className}`}>
{children}
@@ -222,7 +222,7 @@ 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(
tourDayRowIcon(tour.tour_type === 'kayak' ? 'kayak' : 'walk'),
tourDayRowIcon(tour.tour_type === 'kayak' || tour.tour_type === 'bike' ? tour.tour_type : 'walk'),
formatPlannedTourDuration(times.walkingMinutes),
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
'trip-plan-tour-walking-time'
@@ -47,6 +47,7 @@ import { Tooltip } from '../shared/Tooltip';
import TrackColorPicker from '../shared/TrackColorPicker';
import { markdownLinkComponents } from '../shared/markdownLink';
import { TourDayRowAvatar } from './TourDayRowFacts';
import { TourGpxExportButton } from './TourGpxExportButton';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
@@ -588,6 +589,10 @@ export default function TourDetailDialog({
</span>
</Tooltip>
)}
{/* A tour written here offline has no server copy to export until it syncs. */}
{analysis && place.id > 0 && (
<TourGpxExportButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
)}
{canEdit && onSaveAsNew && <SaveAsNewButton onSave={() => onSaveAsNew(tour)} />}
<FooterSpacer />
{canEdit && onDelete && (
@@ -0,0 +1,78 @@
import { Download, Loader2, Share } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { BarButton } from '../Planner/planParts';
import { DialogButton } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { useTourGpxExport, type TourGpxExportTarget } from './useTourGpxExport';
/**
* PR1-06A, IO-01 in a footer: the saved tour as a GPX file, its own action
* beside the navigation, which only leads to the start. "GPX" says the format,
* the tooltip what the file holds. Where the platform takes GPX files, a share
* button beside it hands the same file to the system's share sheet.
*/
export function TourGpxExportButton({ target }: { target: TourGpxExportTarget }) {
const { t } = useTranslation();
const { exportGpx, shareGpx, canShare, exporting } = useTourGpxExport();
return (
<>
<Tooltip label={t('tours.gpxExport.hint')}>
<span className="inline-flex">
<DialogButton
onClick={() => void exportGpx(target)}
disabled={exporting}
aria-label={t('tours.gpxExport.action')}
data-testid="tour-gpx-export"
icon={exporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">GPX</span>
</DialogButton>
</span>
</Tooltip>
{canShare && (
<Tooltip label={t('tours.gpxExport.share')}>
<span className="inline-flex">
<DialogButton
onClick={() => void shareGpx(target)}
disabled={exporting}
aria-label={t('tours.gpxExport.share')}
data-testid="tour-gpx-share"
icon={<Share size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">{t('tours.gpxExport.shareShort')}</span>
</DialogButton>
</span>
</Tooltip>
)}
</>
);
}
/**
* The same export in the planner's head. While the tour has unsaved changes
* the file would not hold them, so the buttons wait for the save and say so.
*/
export function TourGpxExportBarButton({ target, unsaved }: { target: TourGpxExportTarget; unsaved: boolean }) {
const { t } = useTranslation();
const { exportGpx, shareGpx, canShare, exporting } = useTourGpxExport();
return (
<>
<BarButton
label={t(unsaved ? 'tours.gpxExport.saveFirst' : 'tours.gpxExport.action')}
onClick={() => void exportGpx(target)}
disabled={unsaved || exporting}
>
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} strokeWidth={2} />}
</BarButton>
{canShare && (
<BarButton
label={t(unsaved ? 'tours.gpxExport.shareSaveFirst' : 'tours.gpxExport.share')}
onClick={() => void shareGpx(target)}
disabled={unsaved || exporting}
>
<Share size={15} strokeWidth={2} />
</BarButton>
)}
</>
);
}
@@ -1,5 +1,12 @@
import type { PaddleBoatType, PaddleWaterContext, PlannableTourType, TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, FileDown, Info, Mountain, Waves } from 'lucide-react';
import {
BIKE_PROFILES,
type BikeProfile,
type PaddleBoatType,
type PaddleWaterContext,
type PlannableTourType,
type TourGpxImportFields,
} from '@trek/shared';
import { AlertTriangle, FileDown, Info } from 'lucide-react';
import { useId, useMemo, useState } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
@@ -20,6 +27,7 @@ import {
fs,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TourTypeSelect } from './planner/TourTypeSelect';
import { TourNotice } from './tourParts';
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
@@ -76,10 +84,12 @@ export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpx
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
const { t } = useTranslation();
const labelId = useId();
const typeLabelId = 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 [bikeProfile, setBikeProfile] = useState<BikeProfile>('touring');
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
@@ -96,7 +106,9 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
const fields: TourGpxImportFields =
type === 'kayak' && boat && water
? { tour_type: 'kayak', boat_type: boat, water_context: water }
: { tour_type: 'hike' };
: type === 'bike'
? { tour_type: 'bike', bike_profile: bikeProfile }
: { tour_type: 'hike' };
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
return (
@@ -168,18 +180,21 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
)}
</DialogSection>
<DialogSection label={t('tours.import.previewAs')}>
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
<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} /> },
]}
/>
<TourTypeSelect value={type} onChange={setType} labelId={typeLabelId} />
{type === 'bike' && (
<div data-testid="gpx-preview-bike" className="min-w-0">
<span className={LABEL}>{t('tours.bike.profile')}</span>
<Segmented<BikeProfile>
value={bikeProfile}
onChange={setBikeProfile}
label={t('tours.bike.profile')}
fill
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
/>
</div>
)}
{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">
@@ -177,3 +177,26 @@ export default function TourListFilterMenu({ filters }: { filters: TourListFilte
</div>
);
}
/** What a list says when its filters leave nothing: so, and a way to clear them. */
export function TourFilterEmpty({ filters }: { filters: TourListFilters }) {
const { t } = useTranslation();
return (
<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>
);
}
@@ -0,0 +1,202 @@
import type { TourListItem, TourTypeKey } from '@trek/shared';
import {
ArrowDown,
ArrowUp,
CalendarClock,
ChartColumnBig,
ChevronDown,
Clock,
FileDown,
Mountain,
Route,
Ruler,
Waves,
type LucideIcon,
} from 'lucide-react';
import { useMemo, type ComponentType } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { fs } from '../shared/DialogShell';
import { useRailFold } from './railFold';
import { TourDayRowPill } from './TourDayRowFacts';
import { InfoDot, RailCard } from './tourParts';
import { formatPlannedTourDuration } from './tourPresentation';
import { tourScopeSummary, type ScopeSum, type TourTypeScope } from './tourScope';
type Translate = (key: string, params?: Record<string, unknown>) => string;
const TYPE_ICON: Partial<Record<TourTypeKey, ComponentType<{ size?: number; strokeWidth?: number }>>> = {
hike: Mountain,
kayak: Waves,
};
/** A count as a white badge of the card: the number, then what it counts. */
function CountBadge({ value, label, tone = 'neutral' }: { value: number; label: string; tone?: 'neutral' | 'faint' }) {
return (
<span
className={`inline-flex flex-none items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 shadow-sm ring-1 ring-inset ring-edge-faint ${tone === 'faint' ? 'text-content-muted' : 'text-content-secondary'}`}
style={fs(11, 'body')}
>
<span className="font-geist font-semibold tabular-nums text-content">{value}</span>
{label}
</span>
);
}
/** One measure of a type as a badge: its icon and sum, its name (and how complete it is) in the tooltip. */
interface ScopeBadge {
key: string;
icon: LucideIcon;
value: string;
label: string;
}
/**
* A sum as a badge: nothing when no tour has the value, else the sum, marked
* as at least that much when some tours lack it, the tooltip saying for how many.
*/
function sumBadge(
{ key, icon, name }: Pick<ScopeBadge, 'key' | 'icon'> & { name: string },
sum: ScopeSum,
format: (value: number) => string,
t: Translate
): ScopeBadge | null {
if (sum.value === null) return null;
const partial = sum.known < sum.of;
const value = partial ? `≥ ${format(sum.value)}` : format(sum.value);
const label = partial
? `${name}: ${value} (${t('tours.scope.partialBody', { known: sum.known, of: sum.of })})`
: `${name}: ${value}`;
return { key, icon, value, label };
}
function typeBadges(scope: TourTypeScope, unit: Parameters<typeof formatDistance>[1], t: Translate): ScopeBadge[] {
const distance = (km: number) => formatDistance(km, unit);
const height = (m: number) => formatElevation(m, unit);
return [
sumBadge({ key: 'distance', icon: Ruler, name: t('tours.detail.distance') }, scope.distanceKm, distance, t),
sumBadge({ key: 'ascent', icon: ArrowUp, name: t('tours.detail.ascent') }, scope.ascentM, height, t),
sumBadge({ key: 'descent', icon: ArrowDown, name: t('tours.detail.descent') }, scope.descentM, height, t),
sumBadge(
{ key: 'routed', icon: Clock, name: t('tours.scope.routed') },
scope.routedMinutes,
formatPlannedTourDuration,
t
),
sumBadge(
{ key: 'imported', icon: FileDown, name: t('tours.scope.imported') },
scope.importedMinutes,
formatPlannedTourDuration,
t
),
sumBadge(
{ key: 'planned', icon: CalendarClock, name: t('tours.scope.planned') },
scope.plannedMinutes,
formatPlannedTourDuration,
t
),
].filter((badge): badge is ScopeBadge => badge !== null);
}
/** One tour type: its name and how many tours, then its sums as a line of badges. */
function TypeScope({ scope }: { scope: TourTypeScope }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const Icon = TYPE_ICON[scope.type] ?? Route;
const badges = typeBadges(scope, unit, t);
return (
<section
aria-label={t(`tourTypes.${scope.type}`)}
className="flex flex-col gap-1.5"
data-testid={`tour-scope-${scope.type}`}
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="grid h-4 w-4 flex-none place-items-center text-content-muted">
<Icon size={13} strokeWidth={2} />
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12, 'body')}>
{t(`tourTypes.${scope.type}`)}
</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
{scope.count}
</span>
</div>
{badges.length > 0 ? (
<div className="flex flex-wrap gap-1.5 ps-[22px]">
{badges.map((badge) => (
<TourDayRowPill
key={badge.key}
icon={badge.icon}
value={badge.value}
label={badge.label}
testId={`tour-scope-${scope.type}-${badge.key}`}
/>
))}
</div>
) : (
<p className="m-0 ps-[22px] text-content-faint" style={fs(11.5, 'body')}>
{t('tours.scope.noMeasures')}
</p>
)}
</section>
);
}
/**
* STA-01: what the trip's tours add up to, above their list, by type: how many,
* how many are on a day, distance, ascent and descent, and the three kinds of
* time apart (the router's, an import's, the planned one with breaks), never
* added up across types. It reads the list as filtered, so a type chosen in the
* filter narrows both. A value no tour has is left out rather than shown as
* zero; a sum some tours lack is marked as at least that much. It starts folded
* to its counts, and opening it is remembered in this browser.
*/
export function TourScopeSummary({ tours }: { tours: readonly TourListItem[] }) {
const { t } = useTranslation();
// Folded until opened: the list is what the rail is for, the totals a look on demand.
const { folded, toggle } = useRailFold('trip-totals', true);
// On a day as the day lists say, not the list's flag, so a tour put on a day counts at once (as its row's badge does).
const assignments = useTripStore((state) => state.assignments);
const scope = useMemo(() => {
const onDays = new Set(Object.values(assignments ?? {}).flatMap((list) => list.map((item) => item.place_id)));
return tourScopeSummary(tours, (tour) => onDays.has(tour.place_id));
}, [assignments, tours]);
if (scope.count === 0) return null;
const toggleLabel = folded ? t('common.expand') : t('common.collapse');
return (
<RailCard
icon={<ChartColumnBig size={14} strokeWidth={2} />}
title={t('tours.scope.title')}
aside={
<span className="flex flex-none items-center gap-0.5">
<InfoDot label={t('tours.scope.title')} description={t('tours.scope.info')} />
<button
type="button"
onClick={toggle}
aria-expanded={!folded}
aria-label={toggleLabel}
data-testid="tour-scope-toggle"
className="grid h-6 w-6 place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ChevronDown size={14} strokeWidth={2.2} className={`transition-transform ${folded ? '' : 'rotate-180'}`} />
</button>
</span>
}
>
<div className="flex flex-wrap items-center gap-1.5" data-testid="tour-scope-counts">
<CountBadge value={scope.count} label={t('tours.mode.tours')} />
<CountBadge value={scope.onDays} label={t('places.planned')} tone="faint" />
<CountBadge value={scope.count - scope.onDays} label={t('places.unplanned')} tone="faint" />
</div>
{!folded && (
<div className="mt-3 flex flex-col gap-3 border-t border-edge-faint pt-3">
{scope.types.map((typeScope) => (
<TypeScope key={typeScope.type} scope={typeScope} />
))}
</div>
)}
</RailCard>
);
}
@@ -17,6 +17,8 @@ export interface TourWorkspaceRailParts {
trackPaddlePlan?: TrackPaddlePlan;
/** The obstacle checklist of the paddle tour open in the planner (PAD-06). */
paddleChecklist?: PaddleChecklist;
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
tourPlaceIds?: ReadonlySet<number>;
}
/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */
@@ -36,6 +38,7 @@ export default function TourWorkspaceRail({
recordedTrack,
trackPaddlePlan,
paddleChecklist,
tourPlaceIds,
} = workspace;
return wanderer.open ? (
<WandererBrowserRail browser={wanderer} />
@@ -48,6 +51,7 @@ export default function TourWorkspaceRail({
recordedTrack={recordedTrack}
trackPaddlePlan={trackPaddlePlan}
paddleChecklist={paddleChecklist}
tourPlaceIds={tourPlaceIds}
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
/>
);
@@ -349,7 +349,8 @@ describe('ToursSidebar', () => {
// 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' }))
fireEvent.click(screen.getByTestId('tour-type-select'))
fireEvent.click(screen.getByTestId('tour-type-option-kayak'))
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()
+2 -9
View File
@@ -9,7 +9,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import EmptyState from '../shared/EmptyState'
import { TourDayMenu } from './tourParts'
import TourListFilterMenu from './TourListFilterMenu'
import TourListFilterMenu, { TourFilterEmpty } from './TourListFilterMenu'
import { useTourListFilters } from './useTourListFilters'
import TourGpxPreviewDialog from './TourGpxPreviewDialog'
import { useTourGpxImport } from './useTourGpxImport'
@@ -97,14 +97,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
{!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>
<TourFilterEmpty filters={filters} />
) : !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>} />
@@ -0,0 +1,152 @@
import type { TourTrailGrade } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { BikeWaysCard } from './BikeWaysCard';
import type { RouteWays } from './routeWays';
import type { RouteWaysState } from './useRouteWays';
const hook = vi.hoisted(() => ({ state: null as unknown, retry: vi.fn(), input: null as unknown }));
vi.mock('./useRouteWays', () => ({
useRouteWays: (input: unknown) => {
hook.input = input;
return { ...(hook.state as object), retry: hook.retry };
},
}));
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-008
const LINE: [number, number][] = [
[47.0, 11.0],
[47.001, 11.0],
[47.002, 11.0],
[47.003, 11.0],
];
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', wayId: 1 },
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', wayId: 3 },
],
};
function planner(overrides: Partial<TourPlannerController> = {}) {
return {
tripId: 4,
mode: { type: 'new-draft' },
isBike: true,
bikeProfile: 'touring',
route: LINE,
routeCurrent: true,
...overrides,
} as unknown as TourPlannerController;
}
const grade = (id: number, mtb: string | null, uphill: string | null = null): TourTrailGrade => ({
id,
mtb_scale: mtb,
mtb_scale_uphill: uphill,
mtb_scale_imba: null,
});
function show(state: Partial<RouteWaysState>, overrides: Partial<TourPlannerController> = {}) {
hook.state = { status: 'ready', ways: WAYS, grades: null, ...state };
return render(<BikeWaysCard planner={planner(overrides)} />);
}
beforeEach(() => {
localStorage.clear();
useTourRouteHighlightStore.getState().clear();
});
afterEach(() => vi.clearAllMocks());
describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
it('FE-TOUR-BIKECARD-001: the surfaces and kinds of way of the route, by share, with their lengths', () => {
show({});
const card = screen.getByTestId('bike-ways-card');
expect(within(screen.getByTestId('bike-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-surface-gravel')).toHaveTextContent('300 m');
expect(screen.getByTestId('bike-ways-row-way-cyclePath')).toBeInTheDocument();
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
expect(card).not.toHaveTextContent('Trail grade');
});
it('FE-TOUR-BIKECARD-002: a row points at its stretches on the map, and lets go when pressed again', () => {
show({});
const row = screen.getByTestId('bike-ways-row-surface-gravel');
fireEvent.click(row);
expect(row).toHaveAttribute('aria-pressed', 'true');
expect(useTourRouteHighlightStore.getState().highlight).toEqual({
id: 'surface-gravel',
lines: [LINE.slice(1, 3)],
paint: 'gravel',
});
fireEvent.click(row);
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
});
it('FE-TOUR-BIKECARD-003: a road bike tour names what is unpaved and shows it on the map', () => {
show({}, { bikeProfile: 'road' } as Partial<TourPlannerController>);
fireEvent.click(screen.getByRole('button', { name: 'Show on the map' }));
expect(useTourRouteHighlightStore.getState().highlight?.id).toBe('unpaved');
expect(useTourRouteHighlightStore.getState().highlight?.lines).toEqual([LINE.slice(1)]);
});
it('FE-TOUR-BIKECARD-004: a mountain bike tour shows its trail grades, uphill apart, the unknown said', () => {
show({
grades: {
status: 'ready',
byWay: new Map([
[1, grade(1, '0')],
[2, grade(2, '2', '3')],
]),
unasked: new Set([3]),
},
});
expect(screen.getByTestId('bike-ways-row-trail-s0')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-trail-s2')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-trail-unasked')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-uphill-u3')).toBeInTheDocument();
});
it('FE-TOUR-BIKECARD-005: grades nobody could be asked for are one notice with a retry, not a row for the whole tour', () => {
show({ grades: { status: 'ready', byWay: new Map(), unasked: new Set([1, 2, 3]) } });
expect(screen.queryByTestId('bike-ways-row-trail-unasked')).toBeNull();
fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
expect(hook.retry).toHaveBeenCalled();
});
it('FE-TOUR-BIKECARD-006: says when it is still asking, offline, or failed with a way to ask again', () => {
const { unmount } = show({ status: 'loading', ways: null });
expect(screen.getByTestId('bike-ways-card')).toHaveTextContent(/./);
unmount();
const offline = show({ status: 'offline', ways: null });
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
offline.unmount();
show({ status: 'failed', ways: null });
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(hook.retry).toHaveBeenCalledTimes(1);
});
it('FE-TOUR-BIKECARD-007: grades on their way or offline say so in their own section', () => {
const { unmount } = show({ grades: { status: 'loading', byWay: new Map(), unasked: new Set() } });
expect(screen.queryByTestId('bike-ways-row-trail-s0')).toBeNull();
unmount();
show({ grades: { status: 'offline', byWay: new Map(), unasked: new Set() } });
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
});
it('FE-TOUR-BIKECARD-008: only for a bike tour being edited, and only the route as planned now', () => {
const { unmount } = show({ status: 'idle', ways: null });
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
unmount();
const hike = show({}, { isBike: false });
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
hike.unmount();
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial<TourPlannerController>);
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, profile: 'touring' });
});
});
@@ -0,0 +1,341 @@
import { AlertTriangle, Info, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { formatDistance } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { CardAction, RailCard, TourNotice } from '../tourParts';
import { bikePaintCss, surfacePaintKey, trailPaintKey, type BikePaintKey } from './bikePaint';
import {
sharesBy,
stretchesOf,
trailClassOf,
UNPAVED,
type ClassShare,
type RouteWayEdge,
type RouteWays,
type SurfaceClass,
type TrailClass,
type WayClass,
} from './routeWays';
import { useRouteWays, type TrailGrades } from './useRouteWays';
const percent = (share: number) => `${Math.round(share * 100)} %`;
interface ShareRowProps<C extends string> {
id: string;
row: ClassShare<C>;
label: string;
paint: BikePaintKey;
/** A dot in the class's colour; a kind of way has none. */
dot: boolean;
ways: RouteWays;
inClass: (edge: RouteWayEdge) => boolean;
}
/**
* One class of the route: its share and length, and a switch that points at
* its stretches on the map. Pressed again, or another row pressed, it lets go.
*/
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
const toggle = () =>
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
return (
<li>
<button
type="button"
aria-pressed={shown}
data-testid={`bike-ways-row-${id}`}
onClick={toggle}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
>
<span
aria-hidden="true"
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
style={dot ? { background: bikePaintCss(paint) } : undefined}
/>
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
{label}
</span>
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
{formatDistance(row.lengthM / 1000, unit)}
</span>
<span className="w-10 flex-none text-end font-geist tabular-nums text-content-secondary" style={fs(11)}>
{percent(row.share)}
</span>
</button>
</li>
);
}
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
function ShareBar<C extends string>({ rows, paintOf }: { rows: ClassShare<C>[]; paintOf: (key: C) => BikePaintKey }) {
return (
<span aria-hidden="true" className="flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary">
{rows.map((row) => (
<span
key={row.key}
className="h-full"
style={{ width: `${row.share * 100}%`, background: bikePaintCss(paintOf(row.key)) }}
/>
))}
</span>
);
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5, 'caption')}>
{label}
</span>
{children}
</div>
);
}
function SurfaceSection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, (edge) => edge.surface), [ways]);
return (
<Section label={t('tours.bike.ways.surface')}>
<ShareBar rows={rows} paintOf={surfacePaintKey} />
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`surface-${row.key}`}
row={row}
label={t(`tours.bike.ways.surface.${row.key}`)}
paint={surfacePaintKey(row.key)}
dot
ways={ways}
inClass={(edge) => edge.surface === row.key}
/>
))}
</ul>
</Section>
);
}
function WaySection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<WayClass>(ways, (edge) => edge.way), [ways]);
return (
<Section label={t('tours.bike.ways.way')}>
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`way-${row.key}`}
row={row}
label={t(`tours.bike.ways.way.${row.key}`)}
paint="way"
dot={false}
ways={ways}
inClass={(edge) => edge.way === row.key}
/>
))}
</ul>
</Section>
);
}
/** BIK-05: the trail grades along a mountain bike tour, downhill and uphill apart, the unknown said. */
function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: TrailGrades; onRetry: () => void }) {
const { t } = useTranslation();
const classOf = useMemo(
() =>
(edge: RouteWayEdge): TrailClass =>
edge.wayId !== null && grades.unasked.has(edge.wayId)
? 'unasked'
: trailClassOf(edge.wayId === null ? undefined : grades.byWay.get(edge.wayId)),
[grades]
);
const uphillOf = useMemo(
() =>
(edge: RouteWayEdge): string => {
const step = edge.wayId === null ? null : grades.byWay.get(edge.wayId)?.mtb_scale_uphill;
return step ? `u${step[0]}` : 'none';
},
[grades]
);
const rows = useMemo(() => sharesBy<TrailClass>(ways, classOf), [ways, classOf]);
const uphill = useMemo(() => sharesBy<string>(ways, uphillOf).filter((row) => row.key !== 'none'), [ways, uphillOf]);
// Nothing could be asked about: said once with a way to ask again, not as a row covering the whole tour.
const noneAnswered =
grades.status === 'ready' && grades.byWay.size === 0 && rows.every((row) => row.key === 'unasked');
const unread = noneAnswered ? 'failed' : grades.status;
if (unread !== 'ready')
return (
<Section label={t('tours.bike.trail.title')}>
<GradesStatus status={unread} onRetry={onRetry} />
</Section>
);
return (
<>
<Section label={t('tours.bike.trail.title')}>
<ShareBar rows={rows} paintOf={trailPaintKey} />
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`trail-${row.key}`}
row={row}
label={t(`tours.bike.trail.${row.key}`)}
paint={trailPaintKey(row.key)}
dot
ways={ways}
inClass={(edge) => classOf(edge) === row.key}
/>
))}
</ul>
</Section>
{uphill.length > 0 && (
<Section label={t('tours.bike.trail.uphillTitle')}>
<ul className="m-0 flex list-none flex-col p-0">
{uphill.map((row) => (
<ShareRow
key={row.key}
id={`uphill-${row.key}`}
row={row}
label={t('tours.bike.trail.uphillStep', { step: row.key.slice(1) })}
paint="way"
dot={false}
ways={ways}
inClass={(edge) => uphillOf(edge) === row.key}
/>
))}
</ul>
</Section>
)}
<span className="text-content-faint" style={fs(11, 'body')}>
{t('tours.bike.trail.note')}
</span>
</>
);
}
function GradesStatus({ status, onRetry }: { status: Exclude<TrailGrades['status'], 'ready'>; onRetry: () => void }) {
const { t } = useTranslation();
if (status === 'loading')
return (
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{t('tours.bike.trail.loading')}
</span>
);
return (
<TourNotice
tone="warning"
icon={status === 'offline' ? <WifiOff size={13} strokeWidth={2} /> : <AlertTriangle size={13} strokeWidth={2} />}
>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t(status === 'offline' ? 'tours.bike.ways.offline' : 'tours.bike.trail.failed')}</span>
{status === 'failed' && (
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={onRetry}>
{t('common.errorRetry')}
</CardAction>
)}
</span>
</TourNotice>
);
}
/** BIK-02: a road bike tour names what of it is known to be unpaved, and points at it on the map. */
function UnpavedNotice({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const unpaved = ways.edges.filter((edge) => UNPAVED.has(edge.surface)).reduce((sum, edge) => sum + edge.lengthM, 0);
if (unpaved === 0) return null;
const show = () =>
useTourRouteHighlightStore
.getState()
.show({ id: 'unpaved', lines: stretchesOf(ways, (edge) => UNPAVED.has(edge.surface)), paint: 'gravel' });
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t('tours.bike.ways.unpavedNotice', { length: formatDistance(unpaved / 1000, unit) })}</span>
<CardAction icon={<Layers size={12} strokeWidth={2} />} onClick={show}>
{t('tours.bike.ways.showOnMap')}
</CardAction>
</span>
</TourNotice>
);
}
/**
* BIK-01 to BIK-05: what the bike tour being planned runs on, its surfaces and
* its kinds of way, and on a mountain bike tour the trail grades. Read off the
* router's description of the route as OpenStreetMap maps it; where the data
* says nothing the card says unknown. Nothing here changes the tour.
*/
export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
const { t } = useTranslation();
const editing = planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved';
const route = planner.routeCurrent ? planner.route : null;
const state = useRouteWays({
tripId: planner.tripId,
active: planner.isBike && editing,
route,
profile: planner.bikeProfile,
});
const { ways } = state;
const unpavedShare = useMemo(
() => (ways ? sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')) : []),
[ways]
);
if (!planner.isBike || !editing || state.status === 'idle') return null;
const unpaved = unpavedShare.find((row) => row.key === 'unpaved');
return (
<RailCard
icon={<Layers size={14} strokeWidth={2} />}
title={t('tours.bike.ways.title')}
foldId="bike-ways"
aside={
unpaved ? (
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved.share) })}</SoftPill>
) : undefined
}
>
<div data-testid="bike-ways-card" className="flex min-w-0 flex-col gap-3">
{state.status === 'loading' && (
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{t('tours.bike.ways.loading')}
</span>
)}
{state.status === 'offline' && (
<TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.bike.ways.offline')}</TourNotice>
)}
{state.status === 'failed' && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t('tours.bike.ways.failed')}</span>
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={state.retry}>
{t('common.errorRetry')}
</CardAction>
</span>
</TourNotice>
)}
{ways && (
<>
{planner.bikeProfile === 'road' && <UnpavedNotice ways={ways} />}
<SurfaceSection ways={ways} />
<WaySection ways={ways} />
{state.grades && <TrailSection ways={ways} grades={state.grades} onRetry={state.retry} />}
<span className="inline-flex gap-1.5 text-content-faint" style={fs(11, 'body')}>
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" />
{t('tours.bike.ways.source')}
</span>
</>
)}
</div>
</RailCard>
);
}
@@ -0,0 +1,46 @@
import { useMapPaint } from '../../Map/mapPaint';
import type { SurfaceClass, TrailClass } from './routeWays';
/**
* The colours of a bike tour's surfaces and trail grades (styles/bike.css),
* by name. The cards take the CSS variable, which follows the theme on its
* own; the map reads them resolved (`useBikePaint`). A kind of way has no
* colour of its own: it is pointed at in the accent.
*/
const BIKE_PAINT_TOKENS = {
paved: '--bike-surface-paved',
rough: '--bike-surface-rough',
compacted: '--bike-surface-compacted',
gravel: '--bike-surface-gravel',
ground: '--bike-surface-ground',
unknown: '--bike-surface-unknown',
s0: '--bike-trail-s0',
s1: '--bike-trail-s1',
s2: '--bike-trail-s2',
s3: '--bike-trail-s3',
s4: '--bike-trail-s4',
way: '--accent',
casing: '--bike-highlight-casing',
} as const;
export type BikePaintKey = keyof typeof BIKE_PAINT_TOKENS;
/** The paint of a trail class; a way without a grade, or not asked about, in the grey of the unknown. */
export function trailPaintKey(key: TrailClass): BikePaintKey {
return key === 'none' || key === 'unasked' ? 'unknown' : key;
}
/** The paint of a surface class. */
export function surfacePaintKey(key: SurfaceClass): BikePaintKey {
return key;
}
/** A colour as a CSS variable, for markup on the page. */
export function bikePaintCss(key: BikePaintKey): string {
return `var(${BIKE_PAINT_TOKENS[key]})`;
}
/** The colours resolved for map paint, again whenever the theme changes. */
export function useBikePaint(): Record<BikePaintKey, string> {
return useMapPaint(BIKE_PAINT_TOKENS);
}
@@ -0,0 +1,27 @@
import type { RouteHighlight } from '../../../store/tourRouteHighlightStore';
import type { BikePaintKey } from './bikePaint';
type Line = [number, number][];
type LineColor = { line: string; casing: string } | undefined;
/**
* The planner map's lines with a highlight laid over them: the route's own
* lines first as they were, the highlighted stretches after, solid, in their
* colour over a rim of the map's own white, so they read over the route.
* Without a highlight the lines stay exactly as they came.
*/
export function withRouteHighlight(
highlight: RouteHighlight | null,
route: Line[] | null,
walking: boolean[] | null,
colors: LineColor[] | null,
paint: Record<BikePaintKey, string>
): { route: Line[] | null; walking: boolean[] | null; colors: LineColor[] | null } {
if (!highlight?.lines.length || !route?.length) return { route, walking, colors };
const color = { line: paint[highlight.paint as BikePaintKey] ?? paint.way, casing: paint.casing };
return {
route: [...route, ...highlight.lines],
walking: walking ? [...walking, ...highlight.lines.map(() => false)] : null,
colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map(() => color)],
};
}
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest';
import { encodePolyline } from '../../../../tests/helpers/polyline';
import { bikePaintCss, surfacePaintKey, trailPaintKey } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
import { routeWaysFrom, sharesBy, stretchesOf, trailClassOf, wayIdsOf, type RouteWays } from './routeWays';
// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-010
const LINE: [number, number][] = [
[47.0, 11.0],
[47.001, 11.0],
[47.002, 11.0],
[47.003, 11.0],
[47.004, 11.0],
];
function answer(edges: Record<string, unknown>[], line = LINE) {
return { shape: encodePolyline(line, 6), edges };
}
const edge = (from: number, to: number, extra: Record<string, unknown> = {}) => ({
begin_shape_index: from,
end_shape_index: to,
length: 0.1,
surface: 'paved_smooth',
use: 'road',
road_class: 'residential',
way_id: 100 + from,
...extra,
});
describe('routeWaysFrom (BIK-01 to BIK-04)', () => {
it('FE-TOUR-WAYS-001: reads each edge into its place on the line, its surface, its kind of way and its way', () => {
const ways = routeWaysFrom(
answer([
edge(0, 1, { surface: 'paved', use: 'cycleway' }),
edge(1, 2, { surface: 'gravel', use: 'track', length: 0.25 }),
edge(2, 3, { surface: 'dirt', use: 'path' }),
edge(3, 4, { surface: 'compacted', use: 'road', road_class: 'primary', cycle_lane: 'dedicated', way_id: 0 }),
])
);
expect(ways?.line).toHaveLength(5);
expect(ways?.edges.map(({ surface, way, wayId, lengthM }) => [surface, way, wayId, lengthM])).toEqual([
['paved', 'cyclePath', 100, 100],
['gravel', 'track', 101, 250],
['ground', 'path', 102, 100],
['compacted', 'cycleLane', null, 100],
]);
});
it('FE-TOUR-WAYS-002: names quiet roads, roads, ferries and what it does not know', () => {
const ways = routeWaysFrom(
answer([
edge(0, 1, { use: 'living_street' }),
edge(1, 2, { use: 'road', road_class: 'secondary', surface: 'paved_rough' }),
edge(2, 3, { use: 'ferry', surface: undefined }),
edge(3, 4, { use: 'something_new', surface: 'wood' }),
])
);
expect(ways?.edges.map(({ way, surface }) => [way, surface])).toEqual([
['quiet', 'paved'],
['road', 'rough'],
['ferry', 'unknown'],
['other', 'unknown'],
]);
});
it('FE-TOUR-WAYS-003: drops an edge without its place on the line, and is null for no answer of that kind', () => {
const ways = routeWaysFrom(
answer([edge(0, 1), { ...edge(1, 2), end_shape_index: 9 }, { ...edge(2, 1) }, { ...edge(2, 3), length: -1 }])
);
expect(ways?.edges).toHaveLength(1);
expect(routeWaysFrom(null)).toBeNull();
expect(routeWaysFrom({ shape: 1, edges: [] })).toBeNull();
expect(routeWaysFrom(answer([]))).toBeNull();
expect(routeWaysFrom(answer([edge(0, 0)], [[47, 11]]))).toBeNull();
});
});
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 300, surface: 'paved', way: 'road', wayId: 1 },
{ from: 1, to: 2, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 3, to: 4, lengthM: 0, surface: 'ground', way: 'path', wayId: null },
],
};
describe('what the line runs on (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-004: shares by class, the largest first, a class with no length left out', () => {
expect(sharesBy(WAYS, (e) => e.surface)).toEqual([
{ key: 'paved', lengthM: 300, share: 0.6 },
{ key: 'gravel', lengthM: 200, share: 0.4 },
]);
expect(sharesBy({ line: LINE, edges: [] }, (e) => e.surface)).toEqual([]);
});
it('FE-TOUR-WAYS-005: the stretches of a class, neighbours joined into one line each', () => {
expect(stretchesOf(WAYS, (e) => e.surface === 'gravel')).toEqual([LINE.slice(1, 4)]);
expect(stretchesOf(WAYS, (e) => e.surface !== 'gravel')).toEqual([LINE.slice(0, 2), LINE.slice(3, 5)]);
expect(stretchesOf(WAYS, () => false)).toEqual([]);
});
it('FE-TOUR-WAYS-006: the ways to grade, each once, in riding order, at most as many as asked', () => {
expect(wayIdsOf(WAYS, 10)).toEqual([1, 2]);
expect(wayIdsOf(WAYS, 1)).toEqual([1]);
});
it('FE-TOUR-WAYS-007: a trail grade by its step, S4 and up together, none where there is none', () => {
expect(trailClassOf({ mtb_scale: '0' })).toBe('s0');
expect(trailClassOf({ mtb_scale: '2+' })).toBe('s2');
expect(trailClassOf({ mtb_scale: '5' })).toBe('s4');
expect(trailClassOf({ mtb_scale: null })).toBe('none');
expect(trailClassOf({ mtb_scale: 'x' })).toBe('none');
expect(trailClassOf(undefined)).toBe('none');
});
});
describe('the paints and the highlight (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-008: a class names its token; an ungraded way is grey', () => {
expect(surfacePaintKey('gravel')).toBe('gravel');
expect(trailPaintKey('s3')).toBe('s3');
expect(trailPaintKey('none')).toBe('unknown');
expect(trailPaintKey('unasked')).toBe('unknown');
expect(bikePaintCss('s1')).toBe('var(--bike-trail-s1)');
});
const paint = { gravel: '#a0522d', way: '#111', casing: '#fff' } as Parameters<typeof withRouteHighlight>[4];
it('FE-TOUR-WAYS-009: lays the highlighted stretches over the route in their paint, solid', () => {
const route = [LINE.slice(0, 3), LINE.slice(2)];
const drawn = withRouteHighlight(
{ id: 'surface:gravel', lines: [LINE.slice(1, 3)], paint: 'gravel' },
route,
[false, true],
null,
paint
);
expect(drawn.route).toEqual([...route, LINE.slice(1, 3)]);
expect(drawn.walking).toEqual([false, true, false]);
expect(drawn.colors).toEqual([undefined, undefined, { line: '#a0522d', casing: '#fff' }]);
const way = withRouteHighlight({ id: 'way:track', lines: [LINE], paint: 'track' }, route, null, null, paint);
expect(way.colors?.[2]).toEqual({ line: '#111', casing: '#fff' });
expect(way.walking).toBeNull();
});
it('FE-TOUR-WAYS-010: without a highlight, or a route to lay it on, the lines stay as they came', () => {
const route = [LINE];
const colors = [{ line: '#00f', casing: '#fff' }];
expect(withRouteHighlight(null, route, [false], colors, paint)).toEqual({ route, walking: [false], colors });
expect(withRouteHighlight({ id: 'x', lines: [], paint: 'gravel' }, route, null, null, paint).route).toBe(route);
expect(withRouteHighlight({ id: 'x', lines: [LINE], paint: 'gravel' }, null, null, null, paint).route).toBeNull();
});
});
@@ -0,0 +1,190 @@
import type { TourTrailGrade } from '@trek/shared';
import { decodePolyline } from '../../Map/transitGeometry';
/**
* BIK-01 to BIK-05: what a bike tour runs on, read from the router's own
* description of the ways along its line (Valhalla's trace attributes): the
* surface, the kind of way, and the OpenStreetMap way behind each stretch.
* Pure, so the classes and their shares are tested apart from the request.
*/
/** Surfaces from smooth to rough; `unknown` where the data names none. */
export type SurfaceClass = 'paved' | 'rough' | 'compacted' | 'gravel' | 'ground' | 'unknown';
/** Kinds of way, the ones a rider plans by. */
export type WayClass = 'cyclePath' | 'cycleLane' | 'quiet' | 'road' | 'track' | 'path' | 'ferry' | 'other';
/**
* mtb:scale steps as a rider reads them: S0 to S3 apart, S4 and up together
* (rare, and all of them expert ground); `none` where OpenStreetMap gives the
* way no grade, `unasked` where it could not be asked this time.
*/
export type TrailClass = 's0' | 's1' | 's2' | 's3' | 's4' | 'none' | 'unasked';
/** Surfaces a road bike's rider calls unpaved (BIK-02): what is no paving of any kind. */
export const UNPAVED: ReadonlySet<SurfaceClass> = new Set(['compacted', 'gravel', 'ground']);
export interface RouteWayEdge {
/** Where on the line the stretch starts and ends, as indices into `RouteWays.line`. */
from: number;
to: number;
lengthM: number;
surface: SurfaceClass;
way: WayClass;
/** The OpenStreetMap way, where the router names it. */
wayId: number | null;
}
export interface RouteWays {
/** The line as the router matched it, which the stretches index into. */
line: [number, number][];
edges: RouteWayEdge[];
}
const SURFACE_OF: Record<string, SurfaceClass> = {
paved_smooth: 'paved',
paved: 'paved',
paved_rough: 'rough',
compacted: 'compacted',
gravel: 'gravel',
dirt: 'ground',
path: 'ground',
};
const PATH_USES = new Set([
'path',
'footway',
'pedestrian',
'bridleway',
'mountain_bike',
'steps',
'sidewalk',
'pedestrian_crossing',
'elevator',
]);
const QUIET_ROAD_CLASSES = new Set(['residential', 'service_other', 'unclassified']);
function wayOf(use: string | undefined, roadClass: string | undefined, cycleLane: string | undefined): WayClass {
if (use === 'cycleway' || use === 'cycle_street') return 'cyclePath';
if (use === 'ferry' || use === 'rail_ferry') return 'ferry';
if (use === 'track') return 'track';
if (use && PATH_USES.has(use)) return 'path';
if (
use === 'living_street' ||
use === 'service_road' ||
use === 'driveway' ||
use === 'alley' ||
use === 'parking_aisle'
)
return 'quiet';
if (use === 'road') {
if (cycleLane === 'dedicated' || cycleLane === 'separated') return 'cycleLane';
return roadClass && QUIET_ROAD_CLASSES.has(roadClass) ? 'quiet' : 'road';
}
return 'other';
}
interface TraceEdge {
surface?: unknown;
use?: unknown;
road_class?: unknown;
cycle_lane?: unknown;
length?: unknown;
way_id?: unknown;
begin_shape_index?: unknown;
end_shape_index?: unknown;
}
const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null);
/**
* The router's answer read into stretches, or null when it is no answer of
* that kind. Edge lengths come in kilometres; an edge without its place on the
* line is dropped rather than placed by guess.
*/
export function routeWaysFrom(answer: unknown): RouteWays | null {
const data = answer as { shape?: unknown; edges?: unknown } | null;
if (!data || typeof data.shape !== 'string' || !Array.isArray(data.edges)) return null;
const line = decodePolyline(data.shape, 6);
if (line.length < 2) return null;
const edges: RouteWayEdge[] = [];
for (const raw of data.edges as TraceEdge[]) {
const from = count(raw.begin_shape_index);
const to = count(raw.end_shape_index);
const km = count(raw.length);
if (from === null || to === null || km === null || to < from || to >= line.length) continue;
const surface = SURFACE_OF[text(raw.surface) ?? ''] ?? 'unknown';
const wayId = count(raw.way_id);
edges.push({
from,
to,
lengthM: km * 1000,
surface,
way: wayOf(text(raw.use), text(raw.road_class), text(raw.cycle_lane)),
wayId: wayId && wayId > 0 ? wayId : null,
});
}
return edges.length ? { line, edges } : null;
}
/** A trail grade's class: the step of mtb:scale, S4 and up together. */
export function trailClassOf(grade: Pick<TourTrailGrade, 'mtb_scale'> | undefined): TrailClass {
const step = grade?.mtb_scale ? Number(grade.mtb_scale[0]) : null;
if (step === null || Number.isNaN(step)) return 'none';
return step >= 4 ? 's4' : (`s${step}` as TrailClass);
}
export interface ClassShare<C extends string> {
key: C;
lengthM: number;
/** Of the whole line, 0 to 1. */
share: number;
}
/** How much of the line falls in each class, the largest first; classes it has none of are left out. */
export function sharesBy<C extends string>(ways: RouteWays, classOf: (edge: RouteWayEdge) => C): ClassShare<C>[] {
const total = ways.edges.reduce((sum, edge) => sum + edge.lengthM, 0);
const sums = new Map<C, number>();
for (const edge of ways.edges) sums.set(classOf(edge), (sums.get(classOf(edge)) ?? 0) + edge.lengthM);
return [...sums]
.filter(([, lengthM]) => lengthM > 0)
.map(([key, lengthM]) => ({ key, lengthM, share: total > 0 ? lengthM / total : 0 }))
.sort((a, b) => b.lengthM - a.lengthM);
}
/** The stretches of the line in one class, neighbours joined, each as its own line for the map. */
export function stretchesOf(ways: RouteWays, inClass: (edge: RouteWayEdge) => boolean): [number, number][][] {
const stretches: [number, number][][] = [];
let open: { from: number; to: number } | null = null;
const close = () => {
if (open && open.to > open.from) stretches.push(ways.line.slice(open.from, open.to + 1));
open = null;
};
for (const edge of ways.edges) {
if (!inClass(edge)) {
close();
continue;
}
if (open && edge.from <= open.to) open.to = Math.max(open.to, edge.to);
else {
close();
open = { from: edge.from, to: edge.to };
}
}
close();
return stretches;
}
/** The ways along the line OpenStreetMap could grade, each once, in the order they are ridden. */
export function wayIdsOf(ways: RouteWays, limit: number): number[] {
const ids: number[] = [];
const seen = new Set<number>();
for (const edge of ways.edges) {
if (edge.wayId === null || seen.has(edge.wayId)) continue;
seen.add(edge.wayId);
ids.push(edge.wayId);
if (ids.length === limit) break;
}
return ids;
}
@@ -0,0 +1,22 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { useHighlightedRoute } from './useHighlightedRoute';
const LINE: [number, number][] = [
[47, 11],
[47.01, 11],
];
describe('useHighlightedRoute (BIK-01 to BIK-05)', () => {
it('lays the highlight over the route while shown, and leaves the route alone otherwise', () => {
act(() => useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [LINE], paint: 'gravel' }));
const shown = renderHook(() => useHighlightedRoute(true, [LINE], null, null));
expect(shown.result.current.route).toHaveLength(2);
expect(shown.result.current.colors?.[1]).toMatchObject({ line: expect.any(String), casing: expect.any(String) });
const hidden = renderHook(() => useHighlightedRoute(false, [LINE], null, null));
expect(hidden.result.current.route).toEqual([LINE]);
act(() => useTourRouteHighlightStore.getState().clear());
expect(shown.result.current.route).toEqual([LINE]);
});
});
@@ -0,0 +1,18 @@
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { useBikePaint } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
type Lines = Parameters<typeof withRouteHighlight>[1];
type Walking = Parameters<typeof withRouteHighlight>[2];
type Colors = Parameters<typeof withRouteHighlight>[3];
/**
* BIK-01 to BIK-05: the planner map's route with the stretches a bike tour's
* card points at laid over it, while `shown` (the route being edited is on
* the map); otherwise the lines as they came.
*/
export function useHighlightedRoute(shown: boolean, route: Lines, walking: Walking, colors: Colors) {
const highlight = useTourRouteHighlightStore((state) => state.highlight);
const paint = useBikePaint();
return withRouteHighlight(shown ? highlight : null, route, walking, colors, paint);
}
@@ -0,0 +1,141 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { encodePolyline } from '../../../../tests/helpers/polyline';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { useRouteWays } from './useRouteWays';
const trace = vi.hoisted(() => ({ valhallaTraceAttributes: vi.fn() }));
const gradesRepo = vi.hoisted(() => ({ grades: vi.fn() }));
const network = vi.hoisted(() => ({ offline: false }));
vi.mock('../../Map/valhallaRoute', () => trace);
vi.mock('../../../repo/tourTrailGradeRepo', () => ({ tourTrailGradeRepo: gradesRepo }));
vi.mock('../../../hooks/useNetworkMode', () => ({ useNetworkMode: () => ({ offline: network.offline }) }));
// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-008
const ROUTE: [number, number][] = [
[47.0, 11.0],
[47.002, 11.0],
[47.004, 11.0],
];
const ANSWER = {
shape: encodePolyline(ROUTE, 6),
edges: [
{ begin_shape_index: 0, end_shape_index: 1, length: 0.2, surface: 'gravel', use: 'track', way_id: 11 },
{ begin_shape_index: 1, end_shape_index: 2, length: 0.2, surface: 'dirt', use: 'path', way_id: 12 },
],
};
type Input = Parameters<typeof useRouteWays>[0];
const input = (overrides: Partial<Input> = {}): Input => ({
tripId: 3,
active: true,
route: ROUTE,
profile: 'touring',
...overrides,
});
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
});
}
beforeEach(() => {
vi.useFakeTimers();
network.offline = false;
trace.valhallaTraceAttributes.mockReset().mockResolvedValue(ANSWER);
gradesRepo.grades.mockReset();
});
afterEach(() => vi.useRealTimers());
describe('useRouteWays (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-HOOK-001: asks nothing while inactive or without a route of two points', () => {
for (const overrides of [{ active: false }, { route: null }, { route: ROUTE.slice(0, 1) }]) {
const { result } = renderHook(() => useRouteWays(input(overrides)));
expect(result.current.status).toBe('idle');
}
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
});
it('FE-TOUR-WAYS-HOOK-002: waits for the route to settle, then reads its ways, without grades for a touring bike', async () => {
const { result } = renderHook(() => useRouteWays(input()));
expect(result.current.status).toBe('loading');
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
await settle();
expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal));
expect(result.current.status).toBe('ready');
expect(result.current.ways?.edges.map((edge) => edge.surface)).toEqual(['gravel', 'ground']);
expect(result.current.grades).toBeNull();
expect(gradesRepo.grades).not.toHaveBeenCalled();
});
it('FE-TOUR-WAYS-HOOK-003: a mountain bike tour asks the grades of its ways, and keeps what could not be asked apart', async () => {
gradesRepo.grades.mockResolvedValue({
ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null }],
unanswered: [12],
});
const { result } = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
await settle();
expect(gradesRepo.grades).toHaveBeenCalledWith(3, { way_ids: [11, 12] }, expect.any(AbortSignal));
expect(result.current.grades?.status).toBe('ready');
expect(result.current.grades?.byWay.get(11)?.mtb_scale).toBe('2');
expect([...(result.current.grades?.unasked ?? [])]).toEqual([12]);
});
it('FE-TOUR-WAYS-HOOK-004: grades that cannot be had say why: offline, or failed', async () => {
gradesRepo.grades.mockRejectedValueOnce(new OnlineOnlyError());
const offline = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
await settle();
expect(offline.result.current.status).toBe('ready');
expect(offline.result.current.grades?.status).toBe('offline');
offline.unmount();
gradesRepo.grades.mockRejectedValueOnce(new Error('overpass'));
const failed = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
await settle();
expect(failed.result.current.grades?.status).toBe('failed');
});
it('FE-TOUR-WAYS-HOOK-005: a route the router cannot describe fails, and retry asks again', async () => {
trace.valhallaTraceAttributes.mockResolvedValueOnce(null);
const { result } = renderHook(() => useRouteWays(input()));
await settle();
expect(result.current.status).toBe('failed');
act(() => result.current.retry());
expect(result.current.status).toBe('loading');
await settle();
expect(trace.valhallaTraceAttributes).toHaveBeenCalledTimes(2);
expect(result.current.status).toBe('ready');
});
it('FE-TOUR-WAYS-HOOK-006: an error on the way fails rather than leaving the card waiting', async () => {
trace.valhallaTraceAttributes.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useRouteWays(input()));
await settle();
expect(result.current.status).toBe('failed');
});
it('FE-TOUR-WAYS-HOOK-007: offline it says so and asks nothing', async () => {
network.offline = true;
const { result } = renderHook(() => useRouteWays(input()));
await settle();
expect(result.current.status).toBe('offline');
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
});
it('FE-TOUR-WAYS-HOOK-008: a new route drops the highlight and the old answer, and leaving takes the highlight down', async () => {
const { result, rerender, unmount } = renderHook((props: Input) => useRouteWays(props), { initialProps: input() });
await settle();
expect(result.current.status).toBe('ready');
useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [ROUTE], paint: 'gravel' });
rerender(input({ route: [...ROUTE, [47.006, 11.0]] }));
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
expect(result.current.status).toBe('loading');
useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [ROUTE], paint: 'gravel' });
unmount();
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
});
});
@@ -0,0 +1,111 @@
import { TOUR_TRAIL_GRADE_MAX_WAYS, type BikeProfile, type TourTrailGrade } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourTrailGradeRepo } from '../../../repo/tourTrailGradeRepo';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { valhallaTraceAttributes } from '../../Map/valhallaRoute';
import { routeWaysFrom, wayIdsOf, type RouteWays } from './routeWays';
/** How long a route has to stay before its ways are asked about, so a route being shaped asks once. */
const SETTLE_MS = 600;
type RouteWaysStatus = 'idle' | 'loading' | 'ready' | 'failed' | 'offline';
export interface TrailGrades {
status: 'loading' | 'ready' | 'failed' | 'offline';
byWay: ReadonlyMap<number, TourTrailGrade>;
/** Ways the data could not be asked about: their grade is unknown, not absent. */
unasked: ReadonlySet<number>;
}
export interface RouteWaysState {
status: RouteWaysStatus;
ways: RouteWays | null;
/** BIK-05, a mountain bike tour's only: the trail grades of its ways. */
grades: TrailGrades | null;
}
interface Answer extends RouteWaysState {
attempt: number;
/** The route and the question it answers; another one makes it stale. */
route: [number, number][];
withGrades: boolean;
}
const IDLE: RouteWaysState = { status: 'idle', ways: null, grades: null };
const NO_GRADES = { byWay: new Map<number, TourTrailGrade>(), unasked: new Set<number>() };
/** The ways of a route, and for a mountain bike tour their grades; the grades' outcome as it came. */
async function askRouteWays(
tripId: number | string,
route: [number, number][],
withGrades: boolean,
signal: AbortSignal,
onWays: (ways: RouteWays) => void
): Promise<RouteWaysState> {
const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal));
if (!ways) return { status: 'failed', ways: null, grades: null };
if (!withGrades) return { status: 'ready', ways, grades: null };
onWays(ways);
const ids = wayIdsOf(ways, TOUR_TRAIL_GRADE_MAX_WAYS);
if (ids.length === 0) return { status: 'ready', ways, grades: { status: 'ready', ...NO_GRADES } };
try {
const answer = await tourTrailGradeRepo.grades(tripId, { way_ids: ids }, signal);
const byWay = new Map(answer.ways.map((grade) => [grade.id, grade]));
return { status: 'ready', ways, grades: { status: 'ready', byWay, unasked: new Set(answer.unanswered) } };
} catch (failure) {
const status = failure instanceof OnlineOnlyError ? 'offline' : 'failed';
return { status: 'ready', ways, grades: { status, ...NO_GRADES } };
}
}
/**
* BIK-01 to BIK-05: what the bike tour being planned runs on, asked of the
* router once its route has settled, and for a mountain bike tour the trail
* grades of its ways. A new route makes the answer on its way stale and takes
* the map's highlight down; nothing of it changes the tour.
*/
export function useRouteWays(input: {
tripId: number | string;
active: boolean;
route: [number, number][] | null;
profile: BikeProfile;
}): RouteWaysState & { retry: () => void } {
const { tripId, active, route, profile } = input;
const { offline } = useNetworkMode();
const [answer, setAnswer] = useState<Answer | null>(null);
const [attempt, setAttempt] = useState(0);
const retry = useCallback(() => setAttempt((value) => value + 1), []);
const withGrades = profile === 'mtb';
const asking = active && !offline && !!route && route.length >= 2;
useEffect(() => {
useTourRouteHighlightStore.getState().clear();
if (!asking || !route) return;
const controller = new AbortController();
const settle = (state: RouteWaysState) => {
if (!controller.signal.aborted) setAnswer({ ...state, route, withGrades, attempt });
};
const timer = window.setTimeout(() => {
askRouteWays(tripId, route, withGrades, controller.signal, (ways) =>
settle({ status: 'ready', ways, grades: { status: 'loading', ...NO_GRADES } })
)
.then(settle)
// Whatever went wrong, the card says it failed and offers to ask again rather than wait for ever.
.catch(() => settle({ status: 'failed', ways: null, grades: null }));
}, SETTLE_MS);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [asking, attempt, route, tripId, withGrades]);
// The highlight belongs to the route it was drawn on.
useEffect(() => () => useTourRouteHighlightStore.getState().clear(), []);
if (!active || !route || route.length < 2) return { ...IDLE, retry };
if (offline) return { status: 'offline', ways: null, grades: null, retry };
const current = answer?.route === route && answer.withGrades === withGrades && answer.attempt === attempt;
return current ? { ...answer, retry } : { status: 'loading', ways: null, grades: null, retry };
}
@@ -1,7 +1,8 @@
import type { TourMaxHikingDifficulty } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { freeLegBetween } from '../planner/tourRouteActions';
import { routeWalkingTour } from '../planner/tourRouting';
import { isTourRoute } from '../planner/tourRouteOutcome';
import { routeBikeTour, routeWalkingTour } from '../planner/tourRouting';
import { paddleFieldsOf } from '../planner/usePaddleFields';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
@@ -41,6 +42,8 @@ export function useGpxConversion(planner: TourPlannerController) {
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';
// BIK-01: a bike import is planned for its profile.
const bikeProfile = tour?.tour_type === 'bike' ? (tour.bike_profile ?? 'touring') : null;
useEffect(() => () => abort.current?.abort(), []);
@@ -63,9 +66,11 @@ export function useGpxConversion(planner: TourPlannerController) {
fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null,
}))
: points;
const routed = await routeWalkingTour(legs, controller.signal, difficulty);
const routed = bikeProfile
? await routeBikeTour(legs, bikeProfile, controller.signal)
: await routeWalkingTour(legs, controller.signal, difficulty);
if (controller.signal.aborted) return;
if (!routed) {
if (!isTourRoute(routed)) {
setState({ ...base, status: 'failed', error: 'routing' });
return;
}
@@ -76,7 +81,7 @@ export function useGpxConversion(planner: TourPlannerController) {
line: routed.coordinates,
comparison: compareRoutes(trackPoints, suggestion),
});
}, [difficulty, paddle, tour, track]);
}, [bikeProfile, difficulty, paddle, tour, track]);
const cancel = useCallback(() => {
abort.current?.abort();
@@ -99,12 +104,14 @@ export function useGpxConversion(planner: TourPlannerController) {
points: current.points,
sourcePlaceId: tour.place_id,
inPlace: how === 'replace',
revision: tour.revision,
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
...(bikeProfile ? { tourType: 'bike' as const, bikeProfile } : {}),
});
if (started) setState(IDLE);
return started;
},
[current.points, current.status, difficulty, paddle, startDerivedTour, tour]
[bikeProfile, current.points, current.status, difficulty, paddle, startDerivedTour, tour]
);
return useMemo(
@@ -0,0 +1,344 @@
import { plannableTourTypeSchema, type TourLibraryItem, type TourTypeKey } from '@trek/shared';
import { AlertTriangle, ArrowUp, Clock, CopyPlus, Library, Loader2, Ruler, Search, WifiOff } from 'lucide-react';
import { useId, useMemo } 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 { EditorField, INPUT, PillSelect } from '../../shared/dialogParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { TourDayRowPill } from '../TourDayRowFacts';
import { RailFacts, TourNotice, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration, tourPlannedTimes } from '../tourPresentation';
import { type TourLibrary } from './useTourLibrary';
type Translate = (key: string, params?: Record<string, unknown>) => string;
type Unit = Parameters<typeof formatDistance>[1];
const ALL = 'all';
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
function measureBadges(tour: TourLibraryItem, unit: Unit, t: Translate) {
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
return [
minutes != null && {
key: 'time',
icon: Clock,
value: formatPlannedTourDuration(minutes),
label: `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(minutes)}`,
},
tour.distance != null && {
key: 'distance',
icon: Ruler,
value: formatDistance(tour.distance, unit),
label: `${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`,
},
tour.elevation_gain != null &&
tour.tour_type !== 'kayak' && {
key: 'ascent',
icon: ArrowUp,
value: formatElevation(tour.elevation_gain, unit),
label: `${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`,
},
].filter((badge) => !!badge);
}
/** One tour of the list: its name, the trip it is in, its measures. */
function LibraryRow({ tour, selected, onChoose }: { tour: TourLibraryItem; selected: boolean; onChoose: () => void }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
return (
<li>
<button
type="button"
onClick={onChoose}
aria-pressed={selected}
data-testid={`tour-library-row-${tour.place_id}`}
className={`flex w-full min-w-0 flex-col gap-1 rounded-[10px] px-2.5 py-2 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
>
<span className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
{tour.name}
</span>
<span className="flex-none text-content-faint" style={fs(11, 'body')}>
{t(`tourTypes.${tour.tour_type}`)}
</span>
</span>
<span className="min-w-0 truncate text-content-muted" style={fs(11, 'body')}>
{tour.trip.title}
</span>
<span className="flex flex-wrap gap-1.5">
{measureBadges(tour, unit, t).map((badge) => (
<TourDayRowPill
key={badge.key}
icon={badge.icon}
value={badge.value}
label={badge.label}
testId={`tour-library-${badge.key}`}
/>
))}
</span>
</button>
</li>
);
}
/** A line of the list's state: loading, offline, failed with a retry, empty, or cut short. */
function ListState({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const { listStatus, list } = library;
if (listStatus === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.library.offline')}</TourNotice>;
if (listStatus === 'error')
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-2">
{t('tours.library.loadFailed')}
<button type="button" onClick={library.retry} className="font-semibold underline underline-offset-2">
{t('tours.planner.retry')}
</button>
</span>
</TourNotice>
);
if (listStatus === 'loading' && !list)
return (
<p role="status" className="m-0 flex items-center gap-2 py-4 text-content-faint" style={fs(12, 'body')}>
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
{t('common.loading')}
</p>
);
if (list && list.tours.length === 0)
return (
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
{list.trips.length === 0 ? t('tours.library.noTrips') : t('tours.library.empty')}
</p>
);
if (list?.truncated) return <TourNotice>{t('tours.library.truncated')}</TourNotice>;
return null;
}
/** The search box and the two filters over the list. */
function ListFilters({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const searchId = useId();
const trips = library.list?.trips ?? [];
const tripOptions = [
{ value: ALL, label: t('tours.library.allTrips') },
...trips.map((trip) => ({ value: String(trip.id), label: trip.title, hint: String(trip.tour_count) })),
];
const typeOptions = [
{ value: ALL, label: t('tours.library.allTypes') },
...plannableTourTypeSchema.options.map((type) => ({ value: type, label: t(`tourTypes.${type}`) })),
];
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-[10px] border border-edge bg-surface-input px-2.5 py-2">
<Search size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
<label htmlFor={searchId} className="sr-only">
{t('tours.library.search')}
</label>
<input
id={searchId}
autoFocus
value={library.filters.query}
onChange={(event) => library.filter({ query: event.target.value })}
placeholder={t('tours.library.search')}
className="min-w-0 flex-1 border-0 bg-transparent text-content outline-none placeholder:text-content-faint"
style={fs(12.5, 'body')}
/>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<PillSelect
quiet
label={t('tours.library.trip')}
value={library.filters.tripFilter === null ? ALL : String(library.filters.tripFilter)}
options={tripOptions}
onChange={(value) => library.filter({ tripFilter: value === ALL ? null : Number(value) })}
/>
<PillSelect
quiet
label={t('tours.filter.type')}
value={library.filters.typeFilter ?? ALL}
options={typeOptions}
onChange={(value) => library.filter({ typeFilter: value === ALL ? null : (value as TourTypeKey) })}
/>
</div>
</div>
);
}
/** The facts of the chosen tour as the preview shows them, each only where the tour has it. */
function previewFacts(tour: TourLibraryItem, waypoints: number, unit: Unit, t: Translate): TourMetricField[] {
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
return [
fact(true, t('tours.library.fromTrip'), () => tour.trip.title),
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
fact(minutes != null, t('tours.planner.plannedTotalDuration'), () => formatPlannedTourDuration(minutes ?? 0)),
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
formatElevation(tour.elevation_gain ?? 0, unit)
),
fact(tour.elevation_loss != null && tour.tour_type !== 'kayak', t('tours.detail.descent'), () =>
formatElevation(tour.elevation_loss ?? 0, unit)
),
fact(waypoints > 0, t('tours.library.controlPoints'), () => String(waypoints)),
].filter((field): field is TourMetricField => field !== null);
}
/** The chosen tour: on a map, its facts, and the name the copy gets. */
function Preview({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const tileUrl = useTileUrl(OFM_POSITRON);
const nameId = useId();
const line = library.preview?.line;
const route = useMemo(
// The client reads the contract's tuples loosely; the points are spelled out as pairs here.
() =>
line && line.length > 1 ? [line.map((point): [number, number] => [Number(point[0]), Number(point[1])])] : [],
[line]
);
const { selected } = library;
if (!selected)
return (
<div className="grid h-full min-h-[240px] place-items-center rounded-[14px] border border-dashed border-edge px-6 text-center">
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12.5, 'body')}>
{t('tours.library.chooseHint')}
</p>
</div>
);
return (
<div className="flex min-w-0 flex-col gap-3" data-testid="tour-library-preview">
<div className="relative h-[240px] flex-none overflow-hidden rounded-[14px] border border-edge-faint bg-surface-secondary">
{route.length > 0 && (
<MapViewAuto
places={[]}
route={route}
tileUrl={tileUrl}
focusPoints={route[0]}
focusKey={String(selected.place_id)}
followSelection={false}
/>
)}
{library.previewStatus === 'loading' && (
<span className="absolute inset-0 grid place-items-center text-content-faint">
<Loader2 size={18} strokeWidth={2.2} className="animate-spin" />
</span>
)}
{(library.previewStatus === 'error' || library.previewStatus === 'offline') && (
<span
className="absolute inset-0 grid place-items-center px-6 text-center text-content-muted"
style={fs(12, 'body')}
>
{t(library.previewStatus === 'offline' ? 'tours.library.offline' : 'tours.library.previewFailed')}
</span>
)}
</div>
<RailFacts facts={previewFacts(selected, library.preview?.waypoint_count ?? 0, unit, t)} />
<EditorField label={t('tours.library.nameHere')} htmlFor={nameId} hint={t('tours.library.independent')}>
<input
id={nameId}
className={INPUT}
value={library.name}
maxLength={255}
onChange={(event) => library.setName(event.target.value)}
/>
</EditorField>
</div>
);
}
/**
* LIB-02: the dialog to take a tour of another trip over into this one. The
* list on the left, the chosen tour on the right; the copy is made only by the
* button in its foot.
*/
/** LIB-02: the dialog that takes a tour of another trip into this one, opened from "Add tour". */
export function TourLibraryDialog({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const labelId = useId();
if (!library.open) return null;
return (
<DialogShell
onClose={library.close}
labelledBy={labelId}
width="wide"
align="top"
blocked={library.taking}
header={
<DialogHeader
tile={
<DialogTile>
<Library size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={library.close}
eyebrow={t('tours.library.eyebrow')}
title={t('tours.library.title')}
sub={t('tours.library.sub')}
subWraps
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={library.close} disabled={library.taking}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="tour-library-confirm"
icon={
library.taking ? (
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
) : (
<CopyPlus size={14} strokeWidth={2} />
)
}
disabled={!library.canConfirm}
onClick={() => void library.confirm()}
>
{t('tours.library.confirm')}
</DialogButton>
</DialogFooter>
}
>
<div className="grid min-h-0 grid-cols-1 gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<section aria-label={t('tours.library.title')} className="flex min-w-0 flex-col gap-3">
<ListFilters library={library} />
<ListState library={library} />
{library.list && library.list.tours.length > 0 && (
<ul
className={`m-0 flex max-h-[420px] list-none flex-col gap-0.5 overflow-y-auto py-0 pe-2 ps-0 transition-opacity [scrollbar-gutter:stable] ${library.listStatus === 'loading' ? 'opacity-60' : ''}`}
data-testid="tour-library-list"
>
{library.list.tours.map((tour) => (
<LibraryRow
key={tour.place_id}
tour={tour}
selected={library.selected?.place_id === tour.place_id}
onChoose={() => library.choose(tour)}
/>
))}
</ul>
)}
</section>
<Preview library={library} />
</div>
</DialogShell>
);
}
@@ -0,0 +1,198 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourCreateResponse, TourLibraryItem, TourLibraryResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourLibraryRepo } from '../../../repo/tourLibraryRepo';
import { TourLibraryDialog } from './TourLibraryDialog';
import { useTourLibrary } from './useTourLibrary';
// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-008
vi.mock('../../Map/MapViewAuto', () => ({
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
<div data-testid="library-map" data-points={route[0]?.length ?? 0} />
),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
function item(over: Partial<TourLibraryItem> & Pick<TourLibraryItem, 'place_id' | 'name'>): TourLibraryItem {
return {
description: null,
tour_type: 'hike',
distance: 8.2,
elevation_gain: 600,
elevation_loss: 590,
duration: 180,
planned_duration_minutes: null,
break_additional_minutes: null,
max_hiking_difficulty: 2,
has_waypoints: true,
origin: 'trek',
trip: { id: 2, title: 'Alps' },
...over,
} as TourLibraryItem;
}
const RIDGE = item({ place_id: 11, name: 'Ridge' });
const LAKE = item({
place_id: 12,
name: 'Lake loop',
tour_type: 'kayak',
distance: null,
elevation_gain: 40,
trip: { id: 3, title: 'Lakes' },
});
const LIST: TourLibraryResponse = {
tours: [RIDGE, LAKE],
trips: [
{ id: 3, title: 'Lakes', tour_count: 1 },
{ id: 2, title: 'Alps', tour_count: 1 },
],
truncated: false,
};
let list: ReturnType<typeof vi.spyOn>;
let detail: ReturnType<typeof vi.spyOn>;
let takeOver: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
list = vi.spyOn(tourLibraryRepo, 'list').mockResolvedValue(LIST);
detail = vi.spyOn(tourLibraryRepo, 'detail').mockResolvedValue({
tour: RIDGE,
line: [
[47, 11],
[47.1, 11.1],
],
waypoint_count: 4,
});
takeOver = vi.spyOn(tourLibraryRepo, 'takeOver').mockResolvedValue({
tour: { place_id: 99, name: 'My ridge' },
waypoints: [],
} as unknown as TourCreateResponse);
});
afterEach(() => vi.restoreAllMocks());
describe('useTourLibrary', () => {
it('FE-TOUR-LIBRARY-001: asks nothing until opened, then lists the other trips’ tours, the asking trip left out', async () => {
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
expect(list).not.toHaveBeenCalled();
act(() => result.current.show());
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
expect(list).toHaveBeenCalledWith({ exclude_trip_id: 5 }, expect.any(AbortSignal));
expect(result.current.list?.tours).toHaveLength(2);
});
it('FE-TOUR-LIBRARY-002: narrows by name after a pause, and by trip and type at once', async () => {
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
act(() => result.current.show());
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
act(() => result.current.filter({ query: ' rid ', tripFilter: 2, typeFilter: 'hike' }));
expect(result.current.listStatus).toBe('loading');
await waitFor(() =>
expect(list).toHaveBeenLastCalledWith(
{ q: 'rid', trip_id: 2, type: 'hike', exclude_trip_id: 5 },
expect.any(AbortSignal)
)
);
});
it('FE-TOUR-LIBRARY-003: says when it is offline or failed, and asks again on retry', async () => {
list.mockRejectedValueOnce(new OnlineOnlyError()).mockRejectedValueOnce(new Error('500'));
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
act(() => result.current.show());
await waitFor(() => expect(result.current.listStatus).toBe('offline'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.listStatus).toBe('error'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
});
it('FE-TOUR-LIBRARY-004: previews the tour chosen and takes it over under the name given, then hands it in', async () => {
const onTakenOver = vi.fn();
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver }));
act(() => result.current.show());
act(() => result.current.choose(RIDGE));
expect(result.current.name).toBe('Ridge');
await waitFor(() => expect(result.current.previewStatus).toBe('ready'));
expect(detail).toHaveBeenCalledWith(11, expect.any(AbortSignal));
act(() => result.current.setName(' My ridge '));
await act(() => result.current.confirm());
expect(takeOver).toHaveBeenCalledWith(5, { source_trip_id: 2, source_place_id: 11, name: 'My ridge' });
expect(onTakenOver).toHaveBeenCalledWith(expect.objectContaining({ place_id: 99 }));
expect(result.current.open).toBe(false);
expect(toast.success).toHaveBeenCalled();
});
it('FE-TOUR-LIBRARY-005: nothing is taken over without a name, and a failed take-over keeps the dialog', async () => {
takeOver.mockRejectedValueOnce(new Error('500'));
const onTakenOver = vi.fn();
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver }));
act(() => result.current.show());
act(() => result.current.choose(RIDGE));
act(() => result.current.setName(' '));
expect(result.current.canConfirm).toBe(false);
await act(() => result.current.confirm());
expect(takeOver).not.toHaveBeenCalled();
act(() => result.current.setName('Ridge'));
await act(() => result.current.confirm());
expect(onTakenOver).not.toHaveBeenCalled();
expect(result.current.open).toBe(true);
expect(toast.error).toHaveBeenCalled();
});
});
describe('TourLibraryDialog', () => {
function Harness({ onTakenOver = vi.fn() }: { onTakenOver?: () => void }) {
const library = useTourLibrary({ tripId: 5, onTakenOver });
// The way in is the rail's Add tour menu (TourAddMenu); here a plain button stands in for it.
return (
<>
<button type="button" data-testid="tour-rail-library" onClick={library.show}>
open
</button>
<TourLibraryDialog library={library} />
</>
);
}
it('FE-TOUR-LIBRARY-006: lists the tours with their trip and measures, a missing one left out', async () => {
render(<Harness />);
fireEvent.click(screen.getByTestId('tour-rail-library'));
const ridge = await screen.findByTestId('tour-library-row-11');
expect(ridge).toHaveTextContent('Ridge');
expect(ridge).toHaveTextContent('Alps');
expect(within(ridge).getByTestId('tour-library-distance')).toHaveTextContent('8.2 km');
const lake = screen.getByTestId('tour-library-row-12');
// No distance known, and a paddle tour's climb says nothing: neither is shown.
expect(within(lake).queryByTestId('tour-library-distance')).toBeNull();
expect(within(lake).queryByTestId('tour-library-ascent')).toBeNull();
expect(screen.getByTestId('tour-library-confirm')).toBeDisabled();
});
it('FE-TOUR-LIBRARY-007: choosing a tour shows it on a map with its facts and the name field; the button takes it over', async () => {
const onTakenOver = vi.fn();
render(<Harness onTakenOver={onTakenOver} />);
fireEvent.click(screen.getByTestId('tour-rail-library'));
fireEvent.click(await screen.findByTestId('tour-library-row-11'));
const preview = screen.getByTestId('tour-library-preview');
expect(await within(preview).findByTestId('library-map')).toHaveAttribute('data-points', '2');
expect(preview).toHaveTextContent('Alps');
expect(preview).toHaveTextContent('4');
expect(within(preview).getByDisplayValue('Ridge')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('tour-library-confirm'));
await waitFor(() => expect(onTakenOver).toHaveBeenCalled());
expect(screen.queryByTestId('tour-library-preview')).toBeNull();
});
it('FE-TOUR-LIBRARY-008: says when the other trips have no tours', async () => {
list.mockResolvedValue({ tours: [], trips: [], truncated: false });
render(<Harness />);
fireEvent.click(screen.getByTestId('tour-rail-library'));
expect(await screen.findByText('Your other trips have no tours yet.')).toBeInTheDocument();
});
});
@@ -0,0 +1,181 @@
import type {
TourLibraryDetailResponse,
TourLibraryItem,
TourLibraryResponse,
TourListItem,
TourTypeKey,
} from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourLibraryRepo } from '../../../repo/tourLibraryRepo';
import { useToast } from '../../shared/Toast';
/** How long typing rests before the list is asked again. */
const SEARCH_PAUSE_MS = 250;
type LoadStatus = 'loading' | 'ready' | 'error' | 'offline';
/** An answer with the request it belongs to, so a newer request reads as loading, never as an older answer. */
interface Answer<T> {
key: string;
status: Exclude<LoadStatus, 'loading'>;
data?: T;
}
const failure = (err: unknown): 'error' | 'offline' => (err instanceof OnlineOnlyError ? 'offline' : 'error');
interface ListFilters {
query: string;
tripFilter: number | null;
typeFilter: TourTypeKey | null;
}
/** The list while the dialog is open: asked at once on opening, then after a pause in typing; the asking trip left out. */
function useLibraryList(
open: boolean,
tripId: number,
{ query, tripFilter, typeFilter }: ListFilters,
attempt: number
) {
const [answer, setAnswer] = useState<Answer<TourLibraryResponse> | null>(null);
const askedRef = useRef(false);
const q = query.trim();
const key = JSON.stringify([q, tripFilter, typeFilter, attempt]);
useEffect(() => {
if (!open) {
askedRef.current = false;
return undefined;
}
const controller = new AbortController();
const ask = () =>
tourLibraryRepo
.list(
{
q: q || undefined,
trip_id: tripFilter ?? undefined,
type: typeFilter ?? undefined,
exclude_trip_id: tripId,
},
controller.signal
)
.then((data) => setAnswer({ key, status: 'ready', data }))
.catch((err: unknown) => {
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
});
const timer = window.setTimeout(() => void ask(), askedRef.current ? SEARCH_PAUSE_MS : 0);
askedRef.current = true;
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [open, key, q, tripFilter, typeFilter, tripId]);
return {
status: (answer?.key === key ? answer.status : 'loading') as LoadStatus,
/** The last list that came in, kept while a newer one loads so the rows do not flicker away. */
data: answer?.data ?? null,
};
}
/** The preview of the tour chosen, read anew for each choice. */
function useLibraryPreview(open: boolean, selected: TourLibraryItem | null, attempt: number) {
const [answer, setAnswer] = useState<Answer<TourLibraryDetailResponse> | null>(null);
const placeId = selected?.place_id ?? null;
const key = `${placeId ?? ''}:${attempt}`;
useEffect(() => {
if (!open || placeId === null) return undefined;
const controller = new AbortController();
tourLibraryRepo
.detail(placeId, controller.signal)
.then((data) => setAnswer({ key, status: 'ready', data }))
.catch((err: unknown) => {
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
});
return () => controller.abort();
}, [open, placeId, key]);
const status: LoadStatus | null = placeId === null ? null : answer?.key === key ? answer.status : 'loading';
return { status, data: status === 'ready' ? (answer?.data ?? null) : null };
}
/**
* LIB-02: taking a tour of another trip over into this one. The dialog lists
* the tours of the user's other trips, searched by name and filtered by trip
* and type, previews the one chosen, and takes it over only on an explicit
* confirm, under the name in the field. Nothing is written before that, and
* closing writes nothing. The new tour then comes into the trip through
* `onTakenOver`, which also opens it.
*/
export function useTourLibrary({
tripId,
onTakenOver,
}: {
tripId: number;
onTakenOver: (tour: TourListItem) => void | Promise<void>;
}) {
const { t } = useTranslation();
const toast = useToast();
const [open, setOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>({ query: '', tripFilter: null, typeFilter: null });
const [selected, setSelected] = useState<TourLibraryItem | null>(null);
const [name, setName] = useState('');
const [taking, setTaking] = useState(false);
const [attempt, setAttempt] = useState(0);
const list = useLibraryList(open, tripId, filters, attempt);
const preview = useLibraryPreview(open, selected, attempt);
const reset = () => {
setOpen(false);
setSelected(null);
setFilters({ query: '', tripFilter: null, typeFilter: null });
};
const close = () => {
if (!taking) reset();
};
const choose = (tour: TourLibraryItem) => {
setSelected(tour);
setName(tour.name);
};
const filter = (change: Partial<ListFilters>) => setFilters((current) => ({ ...current, ...change }));
const confirm = async () => {
const chosenName = name.trim();
if (!selected || !chosenName || taking) return;
setTaking(true);
try {
const result = await tourLibraryRepo.takeOver(tripId, {
source_trip_id: selected.trip.id,
source_place_id: selected.place_id,
name: chosenName,
});
setTaking(false);
reset();
await onTakenOver(result.tour);
toast.success(t('tours.library.taken', { name: result.tour.name }));
} catch (err: unknown) {
setTaking(false);
toast.error(t(failure(err) === 'offline' ? 'tours.library.offline' : 'tours.library.failed'));
}
};
return {
open,
show: useCallback(() => setOpen(true), []),
close,
filters,
filter,
listStatus: list.status,
list: list.data,
selected,
choose,
previewStatus: preview.status,
preview: preview.data,
name,
setName,
taking,
canConfirm: !!selected && name.trim().length > 0 && !taking,
confirm,
retry: () => setAttempt((value) => value + 1),
};
}
export type TourLibrary = ReturnType<typeof useTourLibrary>;
@@ -367,21 +367,25 @@ export function PaddleCheckCard({ checklist }: { checklist: PaddleChecklist }) {
const notice = noticeOf(checklist);
const { summary, check } = checklist;
return (
<RailCard
icon={<ListChecks size={14} strokeWidth={2} />}
title={t('tours.paddle.check.title')}
aside={summary ? <PaddleCheckPill check={summary} /> : null}
>
<div data-testid="paddle-check-card" className="flex flex-col gap-2.5">
{notice && <CheckNoticeLine notice={notice} evaluatedAt={check?.evaluated_at ?? null} />}
{notice === 'new' && <PaddleObstacleNotice />}
{summary && <PaddleCheckDataLine check={summary} />}
{check && <CheckList checklist={checklist} check={check} />}
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.check.notClearance')}
</p>
</div>
<>
<RailCard
icon={<ListChecks size={14} strokeWidth={2} />}
title={t('tours.paddle.check.title')}
foldId="paddle-check"
aside={summary ? <PaddleCheckPill check={summary} /> : null}
>
<div data-testid="paddle-check-card" className="flex flex-col gap-2.5">
{notice && <CheckNoticeLine notice={notice} evaluatedAt={check?.evaluated_at ?? null} />}
{notice === 'new' && <PaddleObstacleNotice />}
{summary && <PaddleCheckDataLine check={summary} />}
{check && <CheckList checklist={checklist} check={check} />}
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.check.notClearance')}
</p>
</div>
</RailCard>
{/* Outside the card's body: an obstacle picked on the map opens its entry with the card folded too. */}
{checklist.dialogKey !== null && <ObstacleEntryDialog checklist={checklist} />}
</RailCard>
</>
);
}
@@ -0,0 +1,36 @@
import type { PaddleLakeStyle } from '@trek/shared';
import { useTranslation } from '../../../i18n';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
const STYLES: readonly PaddleLakeStyle[] = ['direct', 'shore'];
/**
* PAD-11: how the route crosses a lake. Straight over is the rule; along the
* shore is for wind, when a capsize near the bank is a short swim. The wind
* card suggests it on a windy day; here it is chosen.
*/
export function PaddleLakeStyleField({
value,
onChange,
}: {
value: PaddleLakeStyle;
onChange: (style: PaddleLakeStyle) => void;
}) {
const { t } = useTranslation();
return (
<div data-testid="tour-planner-lake-style" className="min-w-0">
<span className={LABEL}>{t('tours.paddle.lakeStyle')}</span>
<Segmented<PaddleLakeStyle>
value={value}
onChange={onChange}
label={t('tours.paddle.lakeStyle')}
fill
options={STYLES.map((style) => ({ value: style, label: t(`tours.paddle.lakeStyle.${style}`) }))}
/>
<span className="mt-1 block text-content-faint" style={fs(11, 'body')}>
{t(`tours.paddle.lakeStyle.${value}Hint`)}
</span>
</div>
);
}
@@ -33,6 +33,8 @@ const PAINT: PaddlePaint = {
access: '#4338ca',
landing: '#0f766e',
portage: '#9a6233',
rapids: '#d97706',
gap: '#dc2626',
disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)',
};
@@ -12,6 +12,35 @@ import {
} from './paddleMapLayer';
const LINE_WEIGHT = { on_route: 4, near: 3, area: 2 } as const;
/**
* The pane the paddle lines are drawn in: under the overlay pane the tour's
* route is in, so a rule that covers a whole river never hides the line
* planned along it. The markers stay in the marker pane above, where they are
* picked.
*/
const LINE_PANE = 'trek-paddle-lines';
/** Makes the line pane on a map that has none yet; undefined where the renderer has no panes, which then stacks by order. */
function ensureLinePane(map: L.Map): string | undefined {
if (typeof map.getPane !== 'function' || typeof map.createPane !== 'function') return undefined;
if (!map.getPane(LINE_PANE)) {
const pane = map.createPane(LINE_PANE);
pane.style.zIndex = '397';
// The app's stylesheet levels every pane (`.leaflet-pane { z-index: 0 !important }`), so
// the order in the map pane decides: right before the overlay pane, under the route.
const overlay = map.getPane('overlayPane');
if (overlay?.parentElement) overlay.parentElement.insertBefore(pane, overlay);
}
return LINE_PANE;
}
/** The line pane, made once per map before the first line is drawn into it. */
function useLinePane(): string | undefined {
const map = useMap();
const [pane] = useState(() => ensureLinePane(map));
return pane;
}
const LINE_OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const;
function iconOf(item: PaddleMapItem, selected: boolean): L.DivIcon {
@@ -36,6 +65,7 @@ function useLabel() {
/**
* The paddle obstacles on the Leaflet map (PAD-05B): each line on a white
* casing in its category's colour, dashed while it is only near the route,
* and under the tour's route so it never hides it,
* and a round marker with its glyph; the quieter, the less it bears on the
* route, and the objects only in the area wait for a closer zoom. A click
* picks the object and goes no further, so it never also sets a waypoint
@@ -44,6 +74,7 @@ function useLabel() {
export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
const label = useLabel();
const map = useMap();
const linePane = useLinePane();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
const selectedRef = layer?.selectedRef ?? null;
@@ -75,6 +106,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
<Polyline
key={`${item.feature.ref}-casing`}
positions={item.line}
pane={linePane}
pathOptions={{
color: item.disc,
weight: LINE_WEIGHT[item.relation] + 3,
@@ -89,6 +121,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
<Polyline
key={`${item.feature.ref}-line`}
positions={item.line}
pane={linePane}
bubblingMouseEvents={false}
pathOptions={{
color: item.color,
@@ -0,0 +1,152 @@
import type { PaddleRouteLeg } from '@trek/shared';
import { AlertTriangle, RotateCcw, Sparkles, Spline, Waves } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import type { PaddleLegAnswer } from '../../../repo/paddleRouteRepo';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { PaddleLegFailure } from '../planner/paddleRouting';
import { lineLengthM } from '../planner/tourFixedLegs';
/**
* PAD-11: what the waypoint list says about a leg the router planned or
* could not plan, and about a point it set itself.
*/
/** A point the route planner set at a portage or a lock it found; touched by the paddler, it becomes theirs. */
export function AutoPointPill() {
const { t } = useTranslation();
// Only an icon: the row keeps its room for the place name, the tooltip says the rest.
return (
<Tooltip label={`${t('tours.paddle.auto')}: ${t('tours.paddle.autoHint')}`}>
<span
data-testid="tour-planner-auto-point"
aria-label={t('tours.paddle.auto')}
className="grid h-[18px] w-[18px] flex-none place-items-center rounded-full bg-surface-secondary text-content-muted"
>
<Sparkles size={10} strokeWidth={2.2} aria-hidden="true" />
</span>
</Tooltip>
);
}
type Note = { key: string; label: string; tone: 'rapids' | 'gap'; length: number };
/** The stretches of a routed leg worth a look: whitewater, a boat pass, a gap in the data. */
function notesOf(
leg: Extract<PaddleRouteLeg, { status: 'ok' }>,
t: (key: string, params?: Record<string, string | number>) => string
): Note[] {
return leg.segments.flatMap((segment, index): Note[] => {
const length = lineLengthM(leg.geometry.slice(segment.from, segment.to + 1) as [number, number][]);
if (segment.kind === 'rapids' && segment.grade != null) {
return [
{ key: `r${index}`, label: t('tours.paddle.note.rapids', { grade: segment.grade }), tone: 'rapids', length },
];
}
if (segment.kind === 'pass')
return [{ key: `p${index}`, label: segment.name ?? t('tours.paddle.note.pass'), tone: 'rapids', length }];
if (segment.kind === 'gap') return [{ key: `g${index}`, label: t('tours.paddle.note.gap'), tone: 'gap', length }];
return [];
});
}
/** Chips under a routed leg for what the paddler should look at on it; nothing when the leg is plain water. */
export function PaddleLegNotes({ leg }: { leg: PaddleLegAnswer | null | undefined }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
if (!leg || leg.status !== 'ok') return null;
const notes = notesOf(leg, t);
if (notes.length === 0) return null;
return (
<li data-testid="tour-planner-leg-notes" className="flex min-w-0 flex-wrap gap-1 py-0.5 ps-[34px]">
{notes.map((note) => (
<Tooltip
key={note.key}
label={t(note.tone === 'gap' ? 'tours.paddle.note.gapHint' : 'tours.paddle.note.rapidsHint')}
>
<span
className={`inline-flex h-[22px] min-w-0 items-center gap-1 rounded-full bg-surface-card pe-1 ps-1.5 font-semibold text-content shadow-sm ring-1 ring-inset ${note.tone === 'gap' ? 'ring-[color:var(--map-paddle-gap)]' : 'ring-[color:var(--map-paddle-rapids)]'}`}
style={fs(11)}
>
{note.tone === 'gap' ? (
<AlertTriangle
size={11}
strokeWidth={2.2}
className="flex-none"
style={{ color: 'var(--map-paddle-gap)' }}
aria-hidden="true"
/>
) : (
<Waves
size={11}
strokeWidth={2.2}
className="flex-none"
style={{ color: 'var(--map-paddle-rapids)' }}
aria-hidden="true"
/>
)}
<span className="min-w-0 truncate">{note.label}</span>
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-normal tabular-nums leading-[16px] text-content-secondary"
style={fs(10)}
>
{formatDistance(note.length / 1000, unit)}
</span>
</span>
</Tooltip>
))}
</li>
);
}
/**
* A leg the router could not plan: why, in plain words, and what to do about
* it. The map draws it straight and red meanwhile, and the tour waits to be
* saved until it is planned or drawn by hand on purpose. Where TREK could not
* fetch the water data of the leg's region (PAD-11C), `message` says why and
* `onLoadRetry` reads the region again.
*/
export function PaddleLegFailureRow({
code,
disabled,
onDrawStraight,
onRetry,
message,
onLoadRetry,
}: {
code: PaddleLegFailure;
disabled: boolean;
onDrawStraight: () => void;
onRetry: () => void;
message?: string | null;
onLoadRetry?: () => void;
}) {
const { t } = useTranslation();
const retry = onLoadRetry ?? (code !== 'not_on_water' && code !== 'no_region' ? onRetry : null);
const action =
'inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40';
return (
<li data-testid="tour-planner-leg-failure" className="flex min-w-0 py-0.5 ps-[34px]">
<div role="alert" className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-danger-soft px-2 py-1.5">
<span className="flex min-w-0 items-center gap-1.5 font-semibold text-danger" style={fs(11.5, 'body')}>
<AlertTriangle size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
<span className="min-w-0">{message ?? t(`tours.paddle.failed.${code}`)}</span>
</span>
<span className="flex flex-wrap gap-1">
<button type="button" disabled={disabled} onClick={onDrawStraight} className={action} style={fs(10.5)}>
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.paddle.drawStraight')}
</button>
{retry && (
<button type="button" disabled={disabled} onClick={retry} className={action} style={fs(10.5)}>
<RotateCcw size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.paddle.retry')}
</button>
)}
</span>
</div>
</li>
);
}
@@ -0,0 +1,73 @@
// FE-PADDLE-PLACE-001 to 004: a put-in, slipway or egress set as the next waypoint from its card (PAD-05B).
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PaddleObstaclePlaceButton } from './PaddleObstaclePlaceButton';
import { placeOf } from './paddleObstaclePlace';
describe('placeOf', () => {
it('FE-PADDLE-PLACE-001: takes a point as it is, the middle of a way and the middle of an outline', () => {
expect(placeOf({ type: 'Point', coordinates: [12.9, 53.2] })).toEqual({ lat: 53.2, lng: 12.9 });
expect(
placeOf({
type: 'LineString',
coordinates: [
[12.9, 53.2],
[12.91, 53.21],
[12.92, 53.22],
],
})
).toEqual({ lat: 53.21, lng: 12.91 });
const middle = placeOf({
type: 'Polygon',
coordinates: [
[
[12, 53],
[12.02, 53],
[12.02, 53.02],
[12, 53.02],
[12, 53],
],
],
});
expect(middle?.lat).toBeCloseTo(53.01, 9);
expect(middle?.lng).toBeCloseTo(12.01, 9);
});
});
describe('PaddleObstaclePlaceButton', () => {
it('FE-PADDLE-PLACE-002: sets a slipway as a put-in at its spot', () => {
const onPlace = vi.fn();
render(
<PaddleObstaclePlaceButton
kind="slipway"
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
onPlace={onPlace}
/>
);
fireEvent.click(screen.getByTestId('paddle-obstacle-place'));
expect(onPlace).toHaveBeenCalledWith(53.2, 12.9);
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Put in here');
});
it('FE-PADDLE-PLACE-003: names an egress as the place to get out', () => {
render(
<PaddleObstaclePlaceButton
kind="egress"
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
onPlace={vi.fn()}
/>
);
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Take out here');
});
it('FE-PADDLE-PLACE-004: offers nothing on an obstacle', () => {
render(
<PaddleObstaclePlaceButton
kind="weir"
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
onPlace={vi.fn()}
/>
);
expect(screen.queryByTestId('paddle-obstacle-place')).toBeNull();
});
});
@@ -0,0 +1,45 @@
import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared';
import { MapPinPlus } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { placeOf } from './paddleObstaclePlace';
/** Where a paddler gets in or out: these places can become a waypoint from their card. */
const PLACEABLE: Partial<Record<PaddleObstacleKind, 'put_in' | 'egress'>> = {
put_in: 'put_in',
slipway: 'put_in',
egress: 'egress',
};
/**
* PAD-05B: a put-in, a slipway or an egress taken over as the tour's next
* waypoint from its card on the map, so the point sits where the paddler
* really gets in or out. Set after a portage asked for at the last point, it
* is where the portage ends.
*/
export function PaddleObstaclePlaceButton({
kind,
geometry,
onPlace,
}: {
kind: PaddleObstacleKind;
geometry: PaddleGeometry;
onPlace: (lat: number, lng: number) => void;
}) {
const { t } = useTranslation();
const role = PLACEABLE[kind];
const spot = role ? placeOf(geometry) : null;
if (!role || !spot) return null;
return (
<button
type="button"
data-testid="paddle-obstacle-place"
onClick={() => onPlace(spot.lat, spot.lng)}
className="mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text transition-opacity hover:opacity-90"
style={fs(12, 'body')}
>
<MapPinPlus size={14} strokeWidth={2.2} aria-hidden="true" />
{t(role === 'put_in' ? 'tours.paddle.placeHere.putIn' : 'tours.paddle.placeHere.egress')}
</button>
);
}
@@ -0,0 +1,78 @@
// FE-PADDLE-REGION-LOAD-ROW-001 to 004: what a leg waiting for its water data shows (PAD-11C).
import type { PaddleRegionLoadState } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PaddleRegionLoadRow } from './PaddleRegionLoadRow';
import { isRegionLoadPending, regionLoadFailure } from './paddleRegionLoad';
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
const t = (key: string, params?: Record<string, unknown>) => `${key}${params ? JSON.stringify(params) : ''}`;
describe('PaddleRegionLoadRow', () => {
it('FE-PADDLE-REGION-LOAD-ROW-001: says the data loads, how far, and that the leg follows on its own', () => {
const onDrawStraight = vi.fn();
render(
<PaddleRegionLoadRow
state={{ state: 'loading', region: REF, progress: { phase: 'download', fraction: 0.45 }, ahead: 0 }}
disabled={false}
onDrawStraight={onDrawStraight}
/>
);
const row = screen.getByTestId('tour-planner-region-load');
expect(row).toHaveTextContent('Loading the water data for Oberpfalz');
expect(row).toHaveTextContent('Downloading 45%');
fireEvent.click(screen.getByRole('button', { name: /Draw straight/ }));
expect(onDrawStraight).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-REGION-LOAD-ROW-002: says a region waits for an admin, and why', () => {
render(
<PaddleRegionLoadRow
state={{ state: 'requested', region: REF, reason: 'too_large' }}
disabled={false}
onDrawStraight={vi.fn()}
/>
);
const row = screen.getByTestId('tour-planner-region-load');
expect(row).toHaveTextContent('has been requested');
expect(row).toHaveTextContent('larger than TREK loads by itself');
});
it('FE-PADDLE-REGION-LOAD-ROW-003: shows the queue before the read starts, and no button once the data is there', () => {
const { unmount } = render(
<PaddleRegionLoadRow
state={{ state: 'loading', region: REF, progress: null, ahead: 2 }}
disabled={false}
onDrawStraight={vi.fn()}
/>
);
expect(screen.getByTestId('tour-planner-region-load')).toHaveTextContent('Waiting for other regions');
unmount();
render(<PaddleRegionLoadRow state={{ state: 'ready', region: REF }} disabled={false} onDrawStraight={vi.fn()} />);
expect(screen.queryByRole('button')).toBeNull();
});
});
describe('region load states', () => {
it('FE-PADDLE-REGION-LOAD-ROW-004: waits on loading and requests, and names every way the data cannot come', () => {
const pending: PaddleRegionLoadState[] = [
{ state: 'ready', region: REF },
{ state: 'requested', region: REF, reason: 'ask' },
];
for (const state of pending) expect(isRegionLoadPending(state)).toBe(true);
expect(isRegionLoadPending(undefined)).toBe(false);
expect(regionLoadFailure({ state: 'declined', region: REF }, t)).toBe(
'tours.paddle.load.declined{"region":"Oberpfalz"}'
);
expect(regionLoadFailure({ state: 'failed', region: REF }, t)).toContain('tours.paddle.load.failed');
expect(regionLoadFailure({ state: 'no_water', region: REF }, t)).toContain('tours.paddle.load.noWater');
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'no_extract' }, t)).toBe(
'tours.paddle.load.noExtract'
);
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'catalog_unavailable' }, t)).toBe(
'tours.paddle.load.catalogUnavailable'
);
expect(regionLoadFailure({ state: 'unavailable', region: REF, reason: 'off' }, t)).toBeNull();
expect(regionLoadFailure(undefined, t)).toBeNull();
});
});
@@ -0,0 +1,99 @@
import type { PaddleRegionLoadState } from '@trek/shared';
import { CloudDownload, Hourglass, Loader2, Spline } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
type Translate = ReturnType<typeof useTranslation>['t'];
function headline(state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>, t: Translate) {
const region = state.region.name;
if (state.state === 'ready') return t('tours.paddle.load.ready', { region });
if (state.state === 'requested') return t('tours.paddle.load.requested', { region });
return t('tours.paddle.load.loading', { region });
}
/** The phase of a load in words, with its share where it is known. */
function phaseText(state: Extract<PaddleRegionLoadState, { state: 'loading' }>, t: Translate, language: string) {
if (!state.progress) return state.ahead > 0 ? t('tours.paddle.load.waiting') : t('tours.paddle.load.phase.download');
const phase = t(`tours.paddle.load.phase.${state.progress.phase}`);
const share = state.progress.fraction;
return share === null ? phase : `${phase} ${new Intl.NumberFormat(language, { style: 'percent' }).format(share)}`;
}
/**
* PAD-11C: a leg waiting for the water data of its region, which TREK loads
* by itself or an admin has to let through. Calm, not red: nothing is wrong,
* the data is on its way, and the leg is planned on its own once it is there.
* Drawing it straight stays possible for whoever does not want to wait.
*/
export function PaddleRegionLoadRow({
state,
disabled,
onDrawStraight,
}: {
state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>;
disabled: boolean;
onDrawStraight: () => void;
}) {
const { t, language } = useTranslation();
const fraction = state.state === 'loading' ? (state.progress?.fraction ?? null) : state.state === 'ready' ? 1 : null;
const busy = state.state !== 'requested';
return (
<li data-testid="tour-planner-region-load" className="flex min-w-0 py-0.5 ps-[34px]">
<div
role="status"
className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-surface-secondary px-2 py-1.5 ring-1 ring-inset ring-edge-faint"
>
<span className="flex min-w-0 items-start gap-1.5 font-semibold text-content" style={fs(11.5, 'body')}>
{busy ? (
<Loader2 size={12} strokeWidth={2.2} className="mt-[2px] flex-none animate-spin" aria-hidden="true" />
) : (
<Hourglass size={12} strokeWidth={2.2} className="mt-[2px] flex-none" aria-hidden="true" />
)}
<span className="min-w-0">{headline(state, t)}</span>
</span>
{state.state === 'loading' && (
<span className="flex min-w-0 flex-col gap-1">
<span className="flex items-center justify-between gap-2 text-content-muted" style={fs(10.5, 'body')}>
<span className="inline-flex min-w-0 items-center gap-1">
<CloudDownload size={11} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
<span className="truncate">{phaseText(state, t, language)}</span>
</span>
</span>
<span
className="relative block h-[3px] w-full overflow-hidden rounded-full bg-surface-tertiary"
aria-hidden="true"
>
<span
className={`absolute inset-y-0 start-0 rounded-full bg-accent transition-[width] duration-500 ${fraction === null ? 'w-1/3 animate-pulse' : ''}`}
style={fraction === null ? undefined : { width: `${Math.max(4, Math.round(fraction * 100))}%` }}
/>
</span>
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.load.hint')}
</span>
</span>
)}
{state.state === 'requested' && state.reason !== 'ask' && (
<span className="leading-snug text-content-muted" style={fs(10.5, 'body')}>
{t(state.reason === 'too_large' ? 'tours.paddle.load.reason.tooLarge' : 'tours.paddle.load.reason.limit')}
</span>
)}
{state.state !== 'ready' && (
<span className="flex flex-wrap gap-1">
<button
type="button"
disabled={disabled}
onClick={onDrawStraight}
className="inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40"
style={fs(10.5)}
>
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.paddle.drawStraight')}
</button>
</span>
)}
</div>
</li>
);
}
@@ -7,8 +7,9 @@ import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { useSettingsStore } from '../../../store/settingsStore';
import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { portageLegBetween } from '../planner/tourRouteActions';
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard';
/** What the card writes for a time it cannot give: an en dash. */
@@ -143,9 +144,9 @@ describe('PaddleTimeCard (PAD-07)', () => {
renderCard({ breakMinutes: 30 });
const region = screen.getByRole('region', { name: 'Time estimate' });
expect(within(region).getByText('Planning values')).toBeInTheDocument();
const info = within(region).getByRole('button', { name: 'Planning values' });
expect(info).toHaveAccessibleDescription(/^TREK starts with 4 km\/h on the water/);
expect(within(region).getByText(/^TREK starts with 4 km\/h on the water/)).toBeInTheDocument();
// The estimate with its breaks rides in the head, so a folded card still says it.
expect(within(region).getByTestId('paddle-time-badge')).toHaveTextContent('3 h 50 min');
expect(within(region).getByText('Estimate')).toBeInTheDocument();
// 200 minutes of water, carrying, getting out and in and the lock, then 30 minutes of breaks.
expect(estimateText()).toBe('3 h 50 min');
@@ -15,7 +15,7 @@ import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { paddlePlanPoints } from '../planner/paddlePlanOf';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { InfoDot, RailCard, TourNotice } from '../tourParts';
import { RailCard, TourNotice } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { PaddleGlyph } from './PaddleGlyph';
import { PORTAGE_CSS_COLOR } from './paddlePaint';
@@ -364,7 +364,7 @@ export function PaddleEstimateBody({
return (
<div data-testid="paddle-time-card" className="flex flex-col gap-3">
<EstimateHead
total={estimate.estimateMinutes + (breakMinutes ?? 0)}
total={paddleTotal(estimate, breakMinutes).minutes}
complete={estimate.complete}
planned={planned}
t={t}
@@ -386,25 +386,47 @@ export function PaddleEstimateBody({
);
}
/** The card's head: its title, and that its values are planning values to adjust. */
export function PaddleTimeCardShell({ children }: { children: ReactNode }) {
/**
* The card: its title with the estimate as a badge (so a folded card still
* says it, in the warning tone while parts are unknown), the figures, and
* under them that its values are starting values to adjust.
*/
export function PaddleTimeCardShell({
children,
estimate,
breakMinutes,
}: {
children: ReactNode;
estimate: PaddleTimeEstimate;
breakMinutes: number | null;
}) {
const { t } = useTranslation();
const total = paddleTotal(estimate, breakMinutes);
return (
<RailCard
icon={<Timer size={14} strokeWidth={2} />}
title={t('tours.paddle.time.title')}
foldId="paddle-time"
aside={
<span className="flex flex-none items-center gap-1">
<SoftPill tone="info">{t('tours.paddle.time.assumption')}</SoftPill>
<InfoDot label={t('tours.paddle.time.assumption')} description={t('tours.paddle.time.assumptionInfo')} />
<span data-testid="paddle-time-badge" className="flex flex-none">
<SoftPill tone={total.complete ? 'neutral' : 'warning'}>{formatPlannedTourDuration(total.minutes)}</SoftPill>
</span>
}
>
{children}
{/* What the starting values are, said once under the figures rather than behind a dot. */}
<p className="m-0 mt-3 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.time.assumptionInfo')}
</p>
</RailCard>
);
}
/** The estimate with the breaks added, as the card's head and its body say it. */
function paddleTotal(estimate: PaddleTimeEstimate, breakMinutes: number | null) {
return { minutes: estimate.estimateMinutes + (breakMinutes ?? 0), complete: estimate.complete };
}
/**
* PAD-07: how the time of a paddle tour comes about. Water at the paddling
* speed, portages at the carrying speed with their time out and in, locks at
@@ -416,7 +438,7 @@ export function PaddleTimeCard({ planner, locked }: { planner: TourPlannerContro
const estimate = planner.paddleEstimate;
if (!estimate || planner.waypoints.length < 2) return null;
return (
<PaddleTimeCardShell>
<PaddleTimeCardShell estimate={estimate} breakMinutes={planner.breakAdditionalMinutes}>
<PaddleEstimateBody
estimate={estimate}
layout={paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints))}
@@ -0,0 +1,53 @@
// FE-PADDLE-WAYPOINT-PARTS-001 to 004: the pending portage at the last point and a portage's way on land (PAD-03).
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PendingPortageRow, PortageLegRow, type PortageWay } from './PaddleWaypointParts';
const way = (extra: Partial<PortageWay> = {}): PortageWay => ({
routed: false,
noWay: false,
searching: false,
onFollow: vi.fn(),
onStraighten: vi.fn(),
...extra,
});
function openPortage(props: Partial<Parameters<typeof PortageLegRow>[0]> = {}) {
render(<PortageLegRow lengthM={320} extraMinutes={null} disabled={false} onExtraChange={vi.fn()} {...props} />);
fireEvent.click(screen.getByRole('button', { expanded: false }));
}
describe('PendingPortageRow', () => {
it('FE-PADDLE-WAYPOINT-PARTS-001: says to set the point where the boat goes in again, and takes the ask back', () => {
const onRemove = vi.fn();
render(<PendingPortageRow disabled={false} onRemove={onRemove} />);
expect(screen.getByTestId('tour-planner-portage-pending')).toHaveTextContent('Portage from here');
fireEvent.click(screen.getByRole('button', { name: 'Do not carry after all' }));
expect(onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-WAYPOINT-PARTS-002: offers no way back on a tour that cannot be edited', () => {
render(<PendingPortageRow disabled onRemove={vi.fn()} />);
expect(screen.queryByRole('button', { name: 'Do not carry after all' })).toBeNull();
});
});
describe('PortageLegRow way', () => {
it('FE-PADDLE-WAYPOINT-PARTS-003: switches between following the path and a straight line', () => {
const straight = way();
openPortage({ way: straight });
fireEvent.click(screen.getByRole('button', { name: 'Follow the path' }));
expect(straight.onFollow).toHaveBeenCalledTimes(1);
const routed = way({ routed: true });
openPortage({ way: routed });
fireEvent.click(screen.getAllByRole('button', { name: 'Straight line' })[1]);
expect(routed.onStraighten).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-WAYPOINT-PARTS-004: says why a carry stayed straight, and that the router is asked', () => {
openPortage({ way: way({ noWay: true }) });
expect(screen.getByTestId('tour-planner-portage-way')).toHaveTextContent('No path without a long detour');
openPortage({ way: way({ searching: true }) });
expect(screen.getAllByTestId('tour-planner-portage-way')[1]).toHaveTextContent('Looking for a path');
});
});
@@ -1,9 +1,10 @@
import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared';
import { ChevronDown, X } from 'lucide-react';
import { ChevronDown, Loader2, X } from 'lucide-react';
import { useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { PaddleGlyph } from './PaddleGlyph';
import { PORTAGE_CSS_COLOR } from './paddlePaint';
@@ -199,6 +200,99 @@ function StopCard({
);
}
/**
* A portage asked for at the last point, before the point it leads to is set:
* dashed in the portage brown, it says where to click next, and can be taken
* back. Once the next point is set it becomes a portage like any other.
*/
export function PendingPortageRow({ disabled, onRemove }: { disabled: boolean; onRemove: () => void }) {
const { t } = useTranslation();
return (
<li data-testid="tour-planner-portage-pending" className="flex min-w-0 py-0.5 ps-[34px]">
<div
className="flex w-full min-w-0 items-start gap-1.5 rounded-[14px] border border-dashed bg-surface-card py-1 pe-1 ps-1"
style={{ borderColor: PORTAGE_CSS_COLOR }}
>
<span
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-surface-card"
style={{ background: PORTAGE_CSS_COLOR }}
>
<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />
</span>
<span className="flex min-w-0 flex-1 flex-col py-[2px]">
<span className="truncate font-semibold text-content" style={fs(11.5, 'body')}>
{t('tours.paddle.portagePending')}
</span>
<span className="leading-snug text-content-muted" style={fs(11, 'body')}>
{t('tours.paddle.portagePendingHint')}
</span>
</span>
{!disabled && (
<button
type="button"
onClick={onRemove}
aria-label={t('tours.paddle.portagePendingOff')}
title={t('tours.paddle.portagePendingOff')}
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
>
<X size={12} strokeWidth={2.4} />
</button>
)}
</div>
</li>
);
}
export interface PortageWay {
/** The portage follows the ways on land (its line has a shape of its own). */
routed: boolean;
/** The router was asked and found no way without a big detour. */
noWay: boolean;
/** The router is being asked right now. */
searching: boolean;
onFollow: () => void;
onStraighten: () => void;
}
/** Whether a portage follows the ways on land or goes straight, and why it stayed straight. */
function PortageWayField({ way, disabled }: { way: PortageWay; disabled: boolean }) {
const { t } = useTranslation();
const value = way.routed ? 'follow' : 'straight';
const hint = way.searching
? t('tours.paddle.portageWay.searching')
: !way.routed && way.noWay
? t('tours.paddle.portageWay.noWay')
: null;
return (
<div className="flex min-w-0 flex-col gap-1" data-testid="tour-planner-portage-way">
<span className="block truncate text-content" style={fs(12, 'body')}>
{t('tours.paddle.portageWay')}
</span>
<fieldset disabled={disabled || way.searching} className="m-0 min-w-0 border-0 p-0">
<Segmented<'follow' | 'straight'>
value={value}
fill
label={t('tours.paddle.portageWay')}
onChange={(next) => (next === 'follow' ? way.onFollow() : way.onStraighten())}
options={[
{
value: 'follow',
label: t('tours.paddle.portageWay.follow'),
icon: way.searching ? <Loader2 size={12} className="animate-spin" aria-hidden="true" /> : undefined,
},
{ value: 'straight', label: t('tours.paddle.portageWay.straight') },
]}
/>
</fieldset>
{hint && (
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
{hint}
</span>
)}
</div>
);
}
/**
* A portage under the point it starts at, in the earth brown the map draws it
* in: its length on land and the time for getting out, unloading and back in
@@ -211,6 +305,7 @@ export function PortageLegRow({
onExtraChange,
position,
onRemove,
way,
}: {
lengthM: number;
extraMinutes: number | null;
@@ -220,6 +315,8 @@ export function PortageLegRow({
position?: string;
/** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */
onRemove?: () => void;
/** On a planned tour: whether the portage follows the ways on land or goes straight, and the switch between them. */
way?: PortageWay;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
@@ -242,6 +339,7 @@ export function PortageLegRow({
{t('tours.paddle.portageInfo')}
</p>
{onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />}
{way && <PortageWayField way={way} disabled={disabled} />}
<StopField
label={t('tours.paddle.portageExtra')}
hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined}
@@ -1,4 +1,4 @@
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-016: the wind card of a paddle tour (PAD-08) and the section that feeds it.
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-018: the wind card of a paddle tour (PAD-08) and the section that feeds it.
// FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in.
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -340,6 +340,30 @@ describe('PaddleWindSection', () => {
});
});
describe('PaddleWindCard on the lakes (PAD-11)', () => {
it('FE-PADDLE-WIND-017: on a windy day offers to keep near the shore, and says so once the shore is chosen', () => {
const onChoose = vi.fn();
const windy = windOf(ready(forecastOf({ hours: [hourOf(12, 22, 34, 270)] })));
const { rerender } = render(<PaddleWindCard wind={windy} lakes={{ value: 'direct', onChoose }} />);
const advice = screen.getByTestId('paddle-wind-shore-advice');
expect(advice).toHaveTextContent('Up to 22 km/h, gusts 34 km/h on this day.');
fireEvent.click(within(advice).getByRole('button', { name: 'Plan near the shore' }));
expect(onChoose).toHaveBeenCalledWith('shore');
rerender(<PaddleWindCard wind={windy} lakes={{ value: 'shore', onChoose }} />);
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
expect(screen.getByText('The lakes are planned near the shore.')).toBeInTheDocument();
});
it('FE-PADDLE-WIND-018: says nothing of the shore on a calm day, nor where no lake is crossed', () => {
const calm = windOf(ready(forecastOf({ hours: [hourOf(12, 8, 14, 270)] })));
const { rerender } = render(<PaddleWindCard wind={calm} lakes={{ value: 'direct', onChoose: vi.fn() }} />);
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
rerender(<PaddleWindCard wind={windOf(ready(forecastOf({ hours: [hourOf(12, 30, 50, 270)] })))} />);
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
});
});
describe('FigureBadge', () => {
it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => {
const { rerender } = render(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />);
@@ -1,4 +1,4 @@
import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared';
import { compassPointOf, type PaddleLakeStyle, type WindForecastResponse, type WindHour } from '@trek/shared';
import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
@@ -8,6 +8,7 @@ import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { RailCard, TourNotice } from '../tourParts';
import { FigureBadge } from './FigureBadge';
import { windForShore } from './lakeStyleAdvice';
import { useTourWind, type TourWind } from './useTourWind';
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -209,19 +210,63 @@ function WindHints({ t }: { t: Translate }) {
);
}
/** The lake style of the tour being edited, for the wind card to suggest the shore on a windy day. */
export interface LakeStyleChoice {
value: PaddleLakeStyle;
onChoose: (style: PaddleLakeStyle) => void;
}
/**
* PAD-11: on a windy day, the offer to keep near the shores on the lakes,
* with the wind that prompts it; once taken, it says the lakes are planned so.
*/
function ShoreAdvice({
wind,
lakes,
t,
}: {
wind: { meanKmh: number; gustsKmh: number };
lakes: LakeStyleChoice;
t: Translate;
}) {
if (lakes.value === 'shore') {
return (
<TourNotice role="status" icon={<Wind size={13} strokeWidth={2} />}>
{t('tours.paddle.wind.shoreTaken')}
</TourNotice>
);
}
return (
<TourNotice tone="warning" icon={<Wind size={13} strokeWidth={2} />}>
<span data-testid="paddle-wind-shore-advice" className="flex flex-wrap items-center gap-x-2 gap-y-1">
{t('tours.paddle.wind.shoreAdvice', { mean: wind.meanKmh, gusts: wind.gustsKmh })}
<button
type="button"
onClick={() => lakes.onChoose('shore')}
className="font-semibold underline-offset-2 hover:underline"
>
{t('tours.paddle.wind.shoreAdopt')}
</button>
</span>
</TourNotice>
);
}
/**
* PAD-08: the wind on the day a paddle tour is planned for, at its start. The
* mean, the gusts and the direction are figures of their own, with the source
* and when it was asked; no value is turned into a verdict, and missing data
* is never shown as calm.
*/
export function PaddleWindCard({ wind }: { wind: TourWind }) {
export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeStyleChoice }) {
const { t, locale } = useTranslation();
const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null;
const shoreWind = ready && lakes ? windForShore(ready) : null;
return (
<RailCard
icon={<Wind size={14} strokeWidth={2} />}
title={t('tours.paddle.wind.title')}
foldId="paddle-wind"
aside={
wind.tourDays.length > 1 ? (
<span className="flex flex-none items-center gap-1">
@@ -249,6 +294,7 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
) : (
<Unavailable wind={wind} t={t} locale={locale} />
)}
{shoreWind && lakes && <ShoreAdvice wind={shoreWind} lakes={lakes} t={t} />}
<WindHints t={t} />
</div>
</RailCard>
@@ -259,10 +305,13 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
export function PaddleWindSection({
placeId,
start,
lakes,
}: {
placeId: number | null;
start: { lat: number; lng: number } | null;
/** Given while the tour is edited and its route crosses a lake: the card may suggest the shore. */
lakes?: LakeStyleChoice;
}) {
const wind = useTourWind(placeId, start);
return <PaddleWindCard wind={wind} />;
return <PaddleWindCard wind={wind} lakes={lakes} />;
}
@@ -159,7 +159,7 @@ export function TrackPaddlePlanCards({ plan, tour }: { plan: TrackPaddlePlan; to
const locked = !plan.canEdit || plan.saving;
return (
<>
<PaddleTimeCardShell>
<PaddleTimeCardShell estimate={plan.estimate} breakMinutes={tour.break_additional_minutes ?? null}>
<PaddleEstimateBody
estimate={plan.estimate}
layout={plan.layout}
@@ -0,0 +1,51 @@
// FE-PADDLE-LAKE-ADVICE-001 to FE-PADDLE-LAKE-ADVICE-002: when the wind makes the shore worth suggesting (PAD-11 with PAD-08).
import type { WindForecastResponse, WindHour } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { crossesLake, windForShore } from './lakeStyleAdvice';
function forecast(hours: Array<Partial<WindHour> & { hour: number }>): WindForecastResponse {
return {
status: 'forecast',
date: '2026-06-01',
lat: 53.4,
lng: 12.7,
timezone: 'Europe/Berlin',
source: 'Open-Meteo',
fetched_at: '2026-05-30T08:00:00Z',
horizon_days: 16,
day: null,
hours: hours.map((hour) => ({ speed_kmh: null, gusts_kmh: null, direction_deg: null, ...hour })),
} as WindForecastResponse;
}
describe('lakeStyleAdvice', () => {
it('FE-PADDLE-LAKE-ADVICE-001: a fresh breeze or strong gusts in the paddling hours suggest the shore; a stormy night does not', () => {
expect(windForShore(forecast([{ hour: 10, speed_kmh: 12, gusts_kmh: 22 }]))).toBeNull();
expect(windForShore(forecast([{ hour: 14, speed_kmh: 21.4, gusts_kmh: 26 }]))).toEqual({
meanKmh: 21,
gustsKmh: 26,
});
expect(windForShore(forecast([{ hour: 14, speed_kmh: 15, gusts_kmh: 31 }]))).toEqual({ meanKmh: 15, gustsKmh: 31 });
expect(windForShore(forecast([{ hour: 3, speed_kmh: 40, gusts_kmh: 70 }]))).toBeNull();
// An hour without a value is no calm hour, but it is no wind either.
expect(windForShore(forecast([{ hour: 12 }]))).toBeNull();
});
it('FE-PADDLE-LAKE-ADVICE-002: only a route that crosses a lake makes the lake style matter', () => {
const lake = { kind: 'lake' as const, from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null };
const leg = (kind: 'lake' | 'water') => ({
status: 'ok' as const,
geometry: [
[53.4, 12.7],
[53.5, 12.7],
] as [number, number][],
segments: [{ ...lake, kind }],
distance_m: 1000,
snapped_m: { from: 0, to: 0 },
});
expect(crossesLake(null)).toBe(false);
expect(crossesLake({ legs: [leg('water'), null] })).toBe(false);
expect(crossesLake({ legs: [leg('water'), leg('lake')] })).toBe(true);
expect(crossesLake({ legs: [{ status: 'failed', code: 'offline', at: null }] })).toBe(false);
});
});
@@ -0,0 +1,32 @@
import type { WindForecastResponse } from '@trek/shared';
import type { PaddleTourRoute } from '../planner/paddleRouting';
/** The hours a day tour is out on the water, as the wind strip shows them. */
const FIRST_HOUR = 6;
const LAST_HOUR = 21;
/**
* Wind from which open water gets rough for a small boat: a moderate breeze
* (Beaufort 4 from 20 km/h) or gusts of 30 km/h, where a crossing turns into
* work and a capsize far from the bank into a long swim.
*/
const MEAN_KMH = 20;
const GUSTS_KMH = 30;
/** Whether the route as planned crosses a lake, so its lake style changes anything. */
export function crossesLake(route: Pick<PaddleTourRoute, 'legs'> | null): boolean {
return !!route?.legs.some((leg) => leg?.status === 'ok' && leg.segments.some((segment) => segment.kind === 'lake'));
}
/**
* PAD-11 with PAD-08: the strongest wind of the paddling hours when it is
* enough to keep near the shores on the lakes, else null. Lakes are crossed
* straight over as a rule and along the shore only in wind; this says when.
*/
export function windForShore(forecast: WindForecastResponse): { meanKmh: number; gustsKmh: number } | null {
const hours = forecast.hours.filter((hour) => hour.hour >= FIRST_HOUR && hour.hour <= LAST_HOUR);
const meanKmh = Math.max(0, ...hours.map((hour) => hour.speed_kmh ?? 0));
const gustsKmh = Math.max(0, ...hours.map((hour) => hour.gusts_kmh ?? 0));
if (meanKmh < MEAN_KMH && gustsKmh < GUSTS_KMH) return null;
return { meanKmh: Math.round(meanKmh), gustsKmh: Math.round(gustsKmh) };
}
@@ -34,6 +34,8 @@ const PAINT: PaddlePaint = {
access: '#4338ca',
landing: '#0f766e',
portage: '#9a6233',
rapids: '#d97706',
gap: '#dc2626',
disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)',
};
@@ -0,0 +1,23 @@
import type { PaddleGeometry } from '@trek/shared';
/**
* The spot a waypoint goes to: the place itself, the middle of a way (a
* slipway drawn as a ramp), or the middle of an area's outline.
*/
export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null {
const line =
geometry.type === 'Point'
? [geometry.coordinates]
: geometry.type === 'LineString'
? geometry.coordinates
: geometry.coordinates[0];
if (!line?.length) return null;
if (geometry.type === 'Polygon') {
const ring = line.slice(0, -1);
const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length;
const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length;
return { lat, lng };
}
const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? [];
return lat === undefined || lng === undefined ? null : { lat, lng };
}
@@ -12,6 +12,8 @@ const PADDLE_PAINT_TOKENS = {
access: '--map-paddle-access',
landing: '--map-paddle-landing',
portage: '--map-portage',
rapids: '--map-paddle-rapids',
gap: '--map-paddle-gap',
disc: '--map-paddle-disc',
shadow: '--map-paddle-shadow',
} as const;
@@ -0,0 +1,35 @@
import type { PaddleRegionLoadState } from '@trek/shared';
import type { useTranslation } from '../../../i18n';
type Translate = ReturnType<typeof useTranslation>['t'];
/** The states in which the data is on its way: the leg waits rather than fails. */
export function isRegionLoadPending(
state: PaddleRegionLoadState | undefined
): state is Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }> {
return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested';
}
/**
* Why a leg stays unplanned once the data cannot come: declined, failed, no
* water near the place, nothing to load. Null where the leg's own reason says
* it best (loading is off, or nothing was asked yet).
*/
export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null {
if (!state) return null;
const region = state.region?.name ?? '';
switch (state.state) {
case 'declined':
return t('tours.paddle.load.declined', { region });
case 'failed':
return t('tours.paddle.load.failed', { region });
case 'no_water':
return t('tours.paddle.load.noWater', { region });
case 'unavailable':
if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract');
if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable');
return null;
default:
return null;
}
}
@@ -0,0 +1,54 @@
// FE-PORTAGE-ROUTE-001 to 004: a portage laid on the ways on land, never on a detour (PAD-03).
import { beforeEach, describe, expect, it, vi } from 'vitest';
const valhallaRun = vi.fn();
vi.mock('../../Map/valhallaRoute', () => ({ valhallaRun: (...args: unknown[]) => valhallaRun(...args) }));
const { routePortageLine } = await import('./portageRoute');
const FROM = { lat: 53.2232, lng: 12.9668 };
const TO = { lat: 53.2229, lng: 12.9701 };
function answer(coordinates: [number, number][]) {
return { legs: [], snapped: [], total: { coordinates, distance: 0, duration: 0 } };
}
describe('routePortageLine', () => {
beforeEach(() => valhallaRun.mockReset());
it('FE-PORTAGE-ROUTE-001: asks the walking router on easy ground and starts and ends at the points themselves', async () => {
valhallaRun.mockResolvedValue(
answer([
[53.2233, 12.9672],
[53.2226, 12.9686],
[53.223, 12.9698],
])
);
const line = await routePortageLine(FROM, TO);
expect(valhallaRun).toHaveBeenCalledWith([FROM, TO], 'walking', [], undefined, { max_hiking_difficulty: 1 });
expect(line?.[0]).toEqual([FROM.lat, FROM.lng]);
expect(line?.[line.length - 1]).toEqual([TO.lat, TO.lng]);
expect(line).toHaveLength(5);
});
it('FE-PORTAGE-ROUTE-002: keeps the carry straight when the way is a long detour', async () => {
valhallaRun.mockResolvedValue(
answer([
[53.2233, 12.9672],
[53.24, 12.968],
[53.223, 12.9698],
])
);
expect(await routePortageLine(FROM, TO)).toBeNull();
});
it('FE-PORTAGE-ROUTE-003: keeps the carry straight when no router answers', async () => {
valhallaRun.mockResolvedValue(null);
expect(await routePortageLine(FROM, TO)).toBeNull();
});
it('FE-PORTAGE-ROUTE-004: takes no line of a single point', async () => {
valhallaRun.mockResolvedValue(answer([[53.2233, 12.9672]]));
expect(await routePortageLine(FROM, TO)).toBeNull();
});
});

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