Compare commits

...

316 Commits

Author SHA1 Message Date
Maurice 4009588078 fix(tours): give the latest stage date a starting value
Sonar flagged the reduce without an initial value; every stage has a date at
that point, so the empty start never wins.
2026-10-20 19:27:58 +02:00
Maurice ef583ede18 fix(notifications): name each wanderer hint text in a key of its own
The three texts of a wanderer hint were picked in a nested conditional, which
the client's key check cannot read, so it counted them as unused.
2026-10-20 19:16:20 +02:00
Maurice 9bc543a387 chore(tours): keep the client ratchets where they were
Snowshoe is no longer exported, useRouteCalculation takes the stage stops
type through dayRoutePlan, and tourPlannerFacts and useTourRoute hand the
saved duration, the ski check and the planned key to small helpers.
2026-10-20 19:07:13 +02:00
Maurice 7291555e95 test(tours): follow the ski tour changes in the tests
The collection tour fixture no longer writes the dropped winter_activity
column, the drafts are saved as version 11, and a hike no longer offers
the pistes layer, which belongs to a ski track only.
2026-10-20 19:07:12 +02:00
Maurice 57f0e2f263 fix(tours): give the tour entities back their generated form
The format run rewrote two files that check:entities holds byte for byte
to the generator's output; .prettierignore excludes them for that reason.
2026-10-20 19:07:12 +02:00
Maurice de043cfb58 fix(tours): keep the get_tour_plan description one string
An apostrophe in the ski sentence ended the quoted description early, so the
tours MCP file no longer parsed and the server could not boot.
2026-10-20 18:53:40 +02:00
Maurice 69df85bfa2 chore(tours): format the ski and winter files 2026-10-20 18:41:55 +02:00
Maurice be6702f3ab fix(tours): keep the shipped ski migrations and follow the ski tour through
The winter label and four-discipline migrations are already on
feat/tours-board-rest, so a database that ran them would refuse to boot
or fail the old CHECK. 045500, 045520 and 046100 are back as they
shipped, and a new migration drops both winter_activity columns and
adds ski_discipline again with the six disciplines. The entity and
table types follow the column to its new place.

German, Hungarian, Indonesian and English no longer name the whole type
with the ski touring word, and the info dot speaks of ski tours and
tracks. search_tour_pois and its wiki row list rides and ski and winter
tours. Snowshoeing gets a snowshoe glyph instead of the walk's
footprints, a ski import without a discipline can be planned over (the
planner asks for it), and an overlay shown by default tries its tiles
afresh each time it comes on.
2026-10-20 18:40:48 +02:00
Maurice 164a3633ac feat(tours): plan ski and winter tours, snowshoeing and winter hiking among them
Ski becomes "Ski & winter", a type the planner plans with six disciplines
(alpine, snowboard, cross-country, ski touring, snowshoeing, winter hiking),
nothing preselected; saving needs a discipline, and a technique for
cross-country. Every leg is drawn straight between the points through
planner/skiRouting.ts, the one place the piste router will plug in, with its
heights from the elevation source and no ski time worked out.

The hike's winter label goes entirely: its migration, column, field, marks,
GPX rule, MCP field and strings. A hike is summer only; a ski tour with the
snowshoe discipline exports as snowshoeing. The unreleased ski migration takes
the six disciplines and the collection migration loses the winter column.

The pistes are offered for a ski tour, shown until switched off, and with no
tour open, never for a hike. Export, copy, collections, stages (untimed),
actual tracks, the atlas, list and day marks (one glyph per discipline), 3D,
planning over a ski import and list_tours/get_tour_plan know the planned ski
tour.
2026-10-20 18:40:48 +02:00
Maurice 204ee2ef86 chore(ci): give the server test typecheck room, format new client files
The server's src and tests together are past 570,000 lines and the test
typecheck needs about 4.3 GB, over Node's default heap; it now gets 8 GB like
the test jobs. Forty new client files are formatted, and the react-hooks/refs
baseline drops by the one warning a split hook removed.
2026-10-20 18:39:58 +02:00
Maurice baba2de344 refactor(planner): hand the day plan its tours as one bundle from the page hook
Which places are tours, their planned times and where their stages run
travel together to the desktop and the phone day plan. useTripPlannerPage
now puts them together as dayPlanTours, and the page passes that bundle
instead of naming the three props at both places.
2026-10-20 18:26:56 +02:00
Maurice 49dd2db040 refactor(planner): project a day's tours by assignment in one place
The day route plan and the day plan sidebar both built the map of a day's
stops projected onto their tours, each stage on its own stretch, by hand.
projectedById in dayTourProjection builds it for both, and the sidebar's
drive runs reuse the route plan's point type instead of spelling it out
twice.
2026-10-20 18:26:00 +02:00
Maurice 49e306ea48 refactor(map): report the GL viewport from a module of its own
reportGlViewport says what a GL map shows, its bounds and its zoom on the
raster tile scale, and has its own test for that scale. MapViewGL only
wires it to moveend and idle, and its test checks that wiring. The GL map
tests put the zoom back to its default before each test, as they do for
every other value a test overrides, instead of each test restoring it.
2026-10-20 18:23:50 +02:00
Maurice 1d7c8a63e2 refactor(tours): hold the planner's routing choices in a hook of their own
The hike's limit and preferences, the bike profile and the run profile were
four states with four refs kept in step by hand in every handler that set
them. useRoutingChoices holds them as one value with one ref, so a history
step, an undo, a new draft and an opened tour each lay them in with one
call, and useTourPlanner is back under the size limit.
2026-10-20 18:21:46 +02:00
Maurice fb208a88e2 test(tours): read the MCP fields and stage edits the way the other tests do
The confirmed field is checked through z.object(...).pick() as the dawarich
and doc-sync tests read their tool shapes, and the stage edits pass typed
waypoints through the request contract as the walk tests do. The previous
forms sent the test typecheck out of memory.
2026-10-20 18:19:00 +02:00
Maurice 5ae8ac8e70 test(tours): type the tours tests against the current contracts
Raw request bodies are objects, the collection snapshot names the run, ski,
winter and track time fields, the walk write says it has no hiking limit,
the MCP field shapes are read as zod shapes, and the stage edits go through
the tour request contract the API parses them with.
2026-10-20 18:05:41 +02:00
Maurice 20bd384feb fix(tours): send a ski import's technique only when one is chosen
The classification request may clear the technique with null; the GPX
import fields take it or leave it out, so the null never reaches them.
2026-10-20 18:05:40 +02:00
Maurice 58302dc9e4 fix(tours): keep the planner page hook's view model its only object return
The map focus helpers split out of useTripPlannerPage returned object
literals ahead of the hook's own return, which the B4A wiring test reads as
the view model. They move to tourMapFocus.ts beside the hook.
2026-10-20 18:05:40 +02:00
Maurice b460cfd9a7 refactor(tours): move non-component exports out of component files and split long hooks
Clears the client lint ratchet: helpers move to sibling .ts files for react-refresh, complex render paths get small helpers, floating promises are voided, the stage rail lets a stale request go during render, and the collections page reads the active list's tours and rights in ActiveCollectionTours. StageNumber is no longer exported.
2026-10-20 17:51:28 +02:00
Maurice 2b8d2e790f chore(shared): format the tours and wanderer locale and spec files 2026-10-20 17:51:27 +02:00
Maurice 2fc9b1991a test(tours): fix client test expectations and mocks
The type order puts run before kayak, the import button reads Import GPX or KML, and the mocks gain what the components now read: routeRun, packing items and links, the map's zoom, the translation provider and the compare store's map flag.
2026-10-20 17:51:27 +02:00
Maurice 4d81180fc1 test(tours): bring the server tests in line with the tours board
Expectations follow createAssignment's options, the stage_ends column, the editor's trip access and the broadcasts of the take-over. Refusals in the run and ski e2e tests go one by one so supertest does not reset a connection under another request. A parity test keeps the source hint enums in step with the contract.
2026-10-20 17:51:27 +02:00
Maurice 0f600d04f5 fix(tours): import the walk icon, keep one stop menu open and count only stage ends a change lost
The activity section used Footprints without importing it. Opening one stop's menu now closes any other. lostStageEnds no longer counts a stop the change kept without its mark as lost twice over. The wanderer watch switch keeps the same tree once the connection is known, so it is not remounted and keeps its focus.
2026-10-20 17:51:26 +02:00
Maurice b53257deaf fix(tours): answer a new tour with its activities and type the change save's outcome
createTour now answers activities: [] as the detail read does, and the change save's transaction declares the shape it returns so the strict typecheck can narrow it.
2026-10-20 17:51:26 +02:00
Maurice b284095ee0 fix(tours): count a multi-day tour in the activity sums only once all its stages are on days
The activity sums never read which stage a day holds, so a multi-day tour
counted its whole route on the last dated stage day while other stages
were on no day yet, and a recording on one stage took the place of the
whole plan beside it. With stages on days of their own, the route now
counts once every stage is on a dated day of the user's, on the last of
them, and is uncounted until then; a recording counts as what was done
and the plan is not added beside it.
2026-10-20 17:00:27 +02:00
Maurice e40e576e39 fix(tours): preset a ski tour's actual track as ski, and cover the run and ride presets
Ski has been an importable type since SKI-02, yet a ski tour's recording
still opened in the GPX preview as a hike. It now opens as ski with the
tour's discipline, and its technique for cross-country. The run profile
fallback and the ride preset get their tests.
2026-10-20 16:57:27 +02:00
Maurice 4d9b84f681 test(tours): give the packing link copy test an id of its own
TOURREPO-012 was used twice in the tours repository test.
2026-10-20 16:56:56 +02:00
Maurice 223b056de2 fix(tours): name a round hike's distance bounds {from} and {to}
The three round hike strings passed formatted distances as {min} and
{max}, and {max} is a quantity param, so i18n parity read them as count
strings outside a plural group and failed. The bounds are now {from} and
{to} in every locale and in the card.
2026-10-20 16:56:45 +02:00
Maurice 981c372640 fix(tours): let only the latest read set a collection's kept tours
Two reads of the same collection, after two changes by other members or
the first read overlapping one, could answer in either order and an older
list replace a newer one. A refused removal also restored the list from
before it, dropping changes read meanwhile; it now only puts its own tour
back.
2026-10-20 16:56:15 +02:00
Maurice 839d736905 fix(atlas): keep tour exclusions right when their answers come back out of order
Each exclusion replaced the whole list with its answer, so two quick
exclusions answered in the other order, or a read answered after an
exclusion landed, put back a tour as counted although the server held it
excluded. Only the answer to the latest request is now taken whole, with
the exclusions still on their way laid over it; an older answer only
confirms its own tour.
2026-10-20 16:55:34 +02:00
Maurice b9391a9921 fix(tours): hold an actual track change back while the tour is offline or queued
Linking or unlinking a recorded track went out even in offline mode and
behind a queued write of the same tour, and its answer replaced the whole
cached tour with the server's older state, losing the local change. Both
now refuse as online only in those cases and for a tour the server has
not got, the view says so, and the answer only updates the cached
tour's recordings.
2026-10-20 16:54:35 +02:00
Maurice 5e0f216b0c fix(tours): name an actual track's metrics and a tour's stages as derived in get_tour_plan
What a recorded track measures is read off its line on every read, and a
multi-day tour's stage count and stops are counted from its stage ends,
yet derived did not list them, so an assistant would take them for saved
facts.
2026-10-20 16:52:08 +02:00
Maurice a249c861e6 fix(tours): read and write a run's pace in the user's distance unit
The pace was always min/km and the run's time sum forced kilometres, so
with distances in miles the run card disagreed with every other figure in
the planner. The pace is still kept per km, but is typed, shown and
checked per mile when distances are in miles, and the time sum reads in
that unit too.
2026-10-20 16:51:34 +02:00
Maurice 2a40b884a1 fix(tours): keep a kept tour's remove button in sight, dimmed
It was invisible until hover or focus, so a phone or tablet had no way to
find it. It now shows dimmed and comes up fully on hover or focus.
2026-10-20 16:48:09 +02:00
Maurice f5be556c27 fix(tours): list a kept tour with its type's marks, pace and stages
The collection copy keeps the winter, run, ski, bike and stage columns,
but its list item only carried the older fields, so a kept ski track lost
its discipline mark, a snowshoe hike its winter mark, a run its pace and
a multi-day tour its stage count. The item now carries them, and a kept
tour's card shows the pace and the stages beside its measures.
2026-10-20 16:48:00 +02:00
Maurice 6b1488994f fix(tours): give an actual track its own mark and a winter tile its label's icon
Footprints stood for a snowshoe hike, an actual track and a walk's moving
time at once, with the snowshoe pill and the track pill side by side in a
tour's head. An actual track now goes by the done mark on the list row,
in the head and on a recording's tile, and a winter tile's corner takes
the icon its head pill shows.
2026-10-20 16:46:08 +02:00
Maurice 2ee4ea1e65 fix(tours): keep a multi-day tour's days in sight in the list row
The stage count sat in the row's fixed-height line of badges, so a tour
with stages and a recorded track wrapped and lost its days badge. The
count now sits on the fold button, a moon with the number at its corner,
or beside the row where it does not unfold, and the days come before the
measures so the ascent is what gets cut.
2026-10-20 16:44:55 +02:00
Maurice a1d683d7bc fix(tours): show a run's ways in the hike's trail details card
A run described its ways in a card of its own, with the bike's sections,
the bike's paint for the SAC grades (T5 and T6 merged) and its own words,
so the same path read differently on a hike and on a run. The run now
uses the hike's card, matched as a run, and keeps only the unpaved share
and the hardest grade in its head. The run's grade labels and the now
unused SAC paint go.
2026-10-20 16:42:47 +02:00
Maurice ae726ed370 fix(tours): time a run's stages at its pace and leave a ride's stages untimed
A multi-day run's stages took a share of the foot router's time while the
run itself is timed at the runner's pace, so the stages did not add up to
the run. A ride has no time at all, so its stages have none either.
2026-10-20 16:38:42 +02:00
Maurice f4750352e6 fix(tours): fit the merged tracks together after integration
Types, test expectations and helpers the seven tracks each changed on their
own, brought in line with the integrated code. tours.service.ts and the tours
repository grew past the size limit with every track and hand their row and
write helpers to modules of their own.
2026-10-20 16:30:55 +02:00
Maurice 65ee472d66 chore(db): regenerate entities and kysely types for the tours board 2026-10-20 15:40:31 +02:00
Maurice 73fa7aeb19 test(tours): give the tests the merged tracks added side by side ids of their own
Runs, winter hikes and ski tracks numbered their ToursRepository tests 016
to 018, as multi-day tours did; the multi-day list_tours tests took
TOURS-MCP-015 and 016 next to get_tour_plan's. They now read TOURREPO-022
to 024 and TOURS-MCP-024 and 025, so every id names one test.
2026-10-20 15:38:07 +02:00
Maurice fbba4fd73b fix(tours): refresh the wanderer watch after a fixed token and truly disable its switch offline
The watch row only re-read its state when the connection came or went, so after
a passing connection test or a newly saved token it kept saying "Token refused"
until a reload. The connection card now hands it a key built from the saved
connection and its last check, and the row re-reads the watch whenever that
changes.

Without a connection the switch was only dimmed: the button stayed in the tab
order and was announced as active. ToggleSwitch gains a disabled prop that
disables the button and marks it aria-disabled, and the reason stays reachable
through a focusable wrapper carrying the tooltip.
2026-10-20 15:35:32 +02:00
Maurice 2e1dca9fe9 feat(tours): let a background check hint at changes of your own wanderer trails
Settings, Integrations, wanderer gains an optional background check of the
trails you imported, off until you switch it on. While on, TREK reads the
short record of each such trail at most once a day (never its GPX file) and
leaves a hint when the trail may have moved on: "may have changed" when only
wanderer's change time moved, "changed" when its name or description proves
it, and "not checkable" when the trail is gone, no longer yours or carries no
change time. Nothing is ever said to be unchanged. A new hint arrives as a
notification that opens the tour (a new ?place= link in the planner); the
tour's provenance shows it with the source check right beside it, and the
card lists all open hints in a dialog, each leading to its tour. The check
reads the source in full as before and spends the hint; taking a change in
stays the confirmed reimport. No tour is ever changed or deleted.

The check only asks through your own connection, only about trails your
account imported into trips you can still open, and stops when you switch it
off, remove the connection, wanderer refuses the token (until a test passes
or a new token is saved) or the admin switches wanderer off; hints and
snapshots stay. A run asks at most 25 connections for at most 10 records
each, one after another, and an instance that fails (an outage, a rate
limit) is left alone for an hour, doubling up to two days.

Server: migration 046120 adds the switch and the run bookkeeping to
wanderer_connections and the tour_source_hints table (one row per snapshot,
for its importer, gone with it). WandererWatchService and an hourly
wanderer-watch job on the CronRegistrar live in the tours domain and read
through WandererService; GET and PUT /api/integrations/wanderer/watch and GET
/api/trips/:tripId/tours/wanderer/sources/:placeId/hint (place_edit, like the
check) answer with @trek/shared contracts. A new user notification event,
wanderer_source_hint, can be muted per channel. Saving a new token now clears
what the last check said about the old one. There is no MCP tool, as for the
rest of the personal wanderer connection. All 27 locales carry the strings,
and the admin wiki page says what the check does.
2026-10-20 15:35:32 +02:00
Maurice aacc7f2c8d fix(tours): open a tour's packing list on Packing, and ask before letting go of it while Packing is off
"Open packing list" switched to the Lists tab but left it on whichever
sub-tab was open last, so someone who had been in the to-dos landed there.
It now picks the packing side first, and a Lists tab already on screen
follows the choice; storage that is blocked no longer throws either.

With the Packing addon off, "Remove link" let go of the link on one click,
and the link cannot be made again until Packing is back on. It now opens
the tour's packing dialog with only the dimmed link and the Remove link and
Cancel buttons, so nothing changes without a second, deliberate click.
2026-10-20 15:35:09 +02:00
Maurice f833a05461 feat(tours): point a tour at its trip's packing list
A tour can now point at the packing list of its trip, as a whole or at one
of its categories (say "Hiking gear" for a hike), and shows it in its detail.
The new Packing list section reads the list live as the user may see it: the
backpack tile with the item count on its corner and the category's own
colour, how many are packed with a thin progress bar, the first items ticked
off or not with their quantity, how many more there are, and a button that
opens the trip's Lists tab. Who may change the tour links one, changes it or
lets go through a dialog that lists the whole list and every category with
its colour and item count, and writes only on its confirm.

It is a reference, never a copy: the items stay in Packing, are ticked off
and edited there, and each traveller sees in the tour only the items Packing
shows them, so an item kept to oneself never shows through a tour. A
category that is renamed, cleared or only holds items of others reads as
missing, an empty one as empty, a link that could not be read says so with a
retry, and while Lists are switched off the link stays, dimmed, and can
still be let go. Removing the link, or the tour, leaves the list and every
item as they are. Nothing suggests or changes an item by itself.

On the server the link is a row of its own in the tours domain
(tour_packing_links, migration 046110), one per tour and gone with it.
GET, PUT and DELETE /api/trips/:tripId/tours/:placeId/packing-link read it
with the trip and change it with place_edit; linking needs the Lists addon
and, for a category, an item of it the user can see. The writes answer the
tour with its link and tell the trip through tours:changed, so the offline
client queues them as writes of the tour and replays them like one. A copied
trip keeps its tours' links, a tour saved as new or taken over does not.
The MCP tools get_tour_packing, link_tour_packing and unlink_tour_packing run
over the same service and checks; the read also hands the tour's facts as
context for suggesting what to pack, which only becomes an item through the
packing tools. The empty category placeholder now lives in @trek/shared so
both sides leave it out alike. All 27 locales carry the new strings, and
the wiki describes the link and the tools.
2026-10-20 15:35:08 +02:00
Maurice 65fe8cd6e5 fix(tours): explain a kept tour without a line, and say the take-over once
Taking a tour kept in a collection into a trip left the user guessing in
two places when the tour had no line. The map box stayed an empty grey
square that read as a broken map; it now says that there is no line yet,
only the waypoints. Why "Line only" is dimmed was only in the tooltip of a
disabled button, which never shows on hover, focus or touch; the reason now
stands under the two choices, and a disabled tile sits in a span so its
tooltip can show as well.

A successful take-over said the same sentence twice, as a toast and as the
dialog's last word with "Open trip". The toast is gone.

The collection strings move out of tours.ts into a tourCollection.ts per
locale, keeping their keys, so no locale's tours file grows past the
1000-line limit. The tours locale spec now holds both files to their own
counts.
2026-10-20 15:34:09 +02:00
Maurice 3fe34061fd feat(tours): keep whole tours in collections and take them into trips
A saved tour can now be kept in a collection, apart from any trip, and
later made into a tour of another trip. The bookmark button in a tour's
footer and in the planner's head (waiting for the save while there are
unsaved changes) opens a dialog with every list of the user; a list they
may only read stays in it, dimmed with a lock and a tooltip. The list
keeps an independent copy: the line, the type, every measure and setting,
the waypoints in order with their roles, the origin, and the trip and tour
it was saved from as they were called then. Its days, its links to places
of the trip and its obstacle decisions stay in the trip, and later changes
to the tour do not reach the copy.

A collection shows its kept tours as cards above its places: the grade
tile with the source badge, measures and the waypoint count as badges,
the trip it came from, and who kept it. The owner or an admin of the list
can remove one after a question; tours made from it stay. "Add to a trip"
shows the kept tour on a map with its facts and numbered waypoints, lists
the user's trips (the ones they may not add tours to dimmed), and asks
outright whether the waypoints come along. "Line only" keeps the line as
saved and adds no waypoints; a tour kept without a line can only bring
them. The new tour has an id of its own, no days and no new places, and
names the trip and tour it was saved from; the dialog then offers to open
that trip. Neither the collection nor the original tour changes.

On the server the kept tours are a table of their own in the tours domain
(collection_tours, migration 046100), each row written whole once. Every
copy, the in-trip "save as new tour", the take-over from another trip and
both directions here, now goes through one copy model (tour-copy.helpers)
instead of a second one. Reading and removing follow the collection's
roles, keeping one also needs access to the source trip, and taking one
over needs place_edit on the target trip; the kept tour is read again
inside the write, so one removed meanwhile leaves no half-made tour. The
MCP tools list_collection_tours, get_collection_tour,
save_tour_to_collection, take_over_collection_tour and
delete_collection_tour run over the same service and checks. All 27
locales carry the new strings, and the wiki describes the flow.
2026-10-20 15:32:47 +02:00
Maurice 3659297af1 feat(tours): import KML and KMZ routes as tours, with a preview first
A route planned in Google My Maps or Google Earth can now become a tour
without converting it to GPX first. The import button and a file dropped on
the tours workspace take .kml and .kmz files beside .gpx, through the same
preview, the same import routes and the same transaction as a GPX file.

The preview shows every line of the file on its own, numbered, with its
distance and heights; two lines of one placemark are numbered after its name
and never joined. Heights count only where they are heights: kept for an
absolute line, dropped for any other altitude mode and for the all-zero
placeholder My Maps writes, so a missing height stays unknown. What the file
holds besides its lines (pins, areas, overlays, links to other files) is
counted in a dashed card, and nothing is fetched from elsewhere.

The KML reading reuses the place import's codec. A KMZ only ever has its KML
entry unpacked, counted byte by byte against the decompressed cap whatever
its header claims. A file that cannot be read is refused with its reason
(damaged, an archive without KML, too large unpacked), and the toast names
the file and the reason. A KML or KMZ import keeps the gpx origin of a file
import; its provenance names the real format from the stored file name.
2026-10-20 15:30:18 +02:00
Maurice f57a0dbd31 fix(tours): let an unwanted stage go and read a tour's stages once
Two review findings of MDT-02 in the planner's list and the day plan:

- A stage asked for while the draft has unsaved changes stayed pending when
  the changes were kept or the tour failed to open, so opening that tour
  later from its row jumped to a stage nobody asked for then. The rail now
  has cancel(), which the discard question calls when it is dismissed and
  the open calls when it fails, and it lets a stage go once the opened
  tour's points are in and none of its stages starts at that stop.
- Every day holding a stage of a tour read the whole tour on its own, so a
  five stage tour sent five identical requests on top of the list's own.
  tourPointsRepo shares one read per trip, tour and revision among the
  views showing it at the same time, through the tour repo as before, and
  drops it once the last view lets go or the read fails.
2026-10-20 15:28:23 +02:00
Maurice 3748bdb815 fix(tours): keep the day assignments repository under the size limit
The stage queries of MDT-02 took DayAssignments.repository.ts past the
1000 lines lint:size allows. Two concerns move out of it instead:

- The stages of a multi-day tour that days hold are a tour question, so
  findTourStage and deleteTourStagesNotIn move to ToursRepository, next to
  isOnDay and listStagePoints, which AssignmentsService already asks. No
  constructor changes, and the tests move with them (TOURREPO-019 to 021).
- The rows the repository answers with and the Kysely table shapes its
  statements are typed against move to day-assignment-rows.ts, so the file
  holds the statements alone and has room again.

tours.service.ts stays at its 1000 lines.
2026-10-20 15:28:23 +02:00
Maurice 8fa75b3c4e feat(tours): show a multi-day tour's stages in the list and put each on a day
A multi-day tour in the planner's list of the trip's tours now unfolds into
its stages (MDT-02), so the stages can be seen together and fitted into the
trip without leaving the one route they are cut from.

- The fold beside a multi-day tour lists its stages in route order, each
  from stop to stop with its distance, climb, descent and moving time, read
  off the saved tour the way the planner reads them, and below them what the
  whole tour adds up to. A value no stage tells is left out, a total some
  stages do not tell is marked as at least that much and says for how many,
  and a stage whose route is not planned yet says so.
- Each stage has its own day: a pill opens the trip's days, moves the stage
  to another one or takes it off again. A stage without a day shows a dashed
  "No day" pill. A day holds a tour, or one of its stages, once, and a stage
  is on one day at most; the plan's own offline path writes it.
- A stage opens in the planner: the tour opens as any tour does and the
  stage is shown once its points are in.
- The day plan names the stage a day holds with its own figures, and the
  day's route runs to that stage's start and on from its end; the trip map
  draws that stretch only. The tour list still counts a tour once, however
  many days hold it or its stages.
- A saved change that takes a stage away (its stage end gone, the tour a day
  tour again, back to the recorded track) takes it off its day in the same
  write and tells every client; the planner says so before the save.
- REST and the assign_place_to_day tool take the stage by the key of the
  stop it starts at, with the same checks; list_tours names the stops
  between the stages and the stage each day holds.

A day assignment remembers its stage by that key (migration 046010); every
assignment made before holds the whole tour, as it did.
2026-10-20 15:28:22 +02:00
Maurice 6ec06c90f4 fix(tours): keep a lost stage end visible on paddle tours
A stage end set on a lock or portage point the water router placed left
the point automatic, so the router could drop it with its leg later,
silently and without an undo step. Ending a stage there now makes the
point the paddler's own, like any other edit of it.

The notice for stage ends a change could not keep also vanished as soon
as the router laid its points in again after the change. It now stays
until the next change the user makes, an undo or redo, a save, or
another draft.
2026-10-20 15:26:43 +02:00
Maurice 2449616ee9 feat(tours): plan a multi-day tour as one route split into stages
A hut-to-hut hike or a paddle trip over several days is now planned in the
Tours planner as one tour: the whole route is planned as before, and stops
along it are marked as stage ends (MDT-01). The stages are read off that one
route in its order, never kept as copies, so a change inside a stage changes
the whole tour and every figure on it.

- A Day tour / Multi-day tour switch beside the tour type; a stop's menu
  ends a stage there or lets it go on. Back to a day tour removes every stage
  end after a confirmation, as one step undo brings back.
- The list of points is headed per stage with its distance and moving time;
  a stop that ends a stage carries a moon at its number, on the map too.
- The overview lists the stages from stop to stop with distance, climb,
  descent and time (hiking rule per stage, paddle estimate per stage, the
  router's time shared by distance otherwise), marks where each ends across
  the elevation profile, and a stage of more than a day on the move warns.
- A stage end a change cannot keep (its stop removed, moved to an end or
  made a shaping point) is dropped and said so, with undo, never moved
  to another point.
- A multi-day tour may take a day a stage, so its time limit grows with its
  stages; the tour keeps its one type, portages included.
- The list and the MCP tools show the stage count and where stages end.

Stage ends are a flag on the waypoint (migration 046000); a tour without one
is a day tour, as every tour saved before.
2026-10-20 15:26:18 +02:00
Maurice acc294dd8e fix(atlas): count shared days for every member, keep routes and multi-day tours honest (STA-02)
A day of a tour that names nobody now counts for every member of the trip,
not only for its owner. In the planner an empty participant list means
everyone, and a full member list is stored back as empty, so members got
nothing for most shared tours. Only a day that names participants without
the user stays theirs. The tours of a shared trip on no day or only on
undated days are now named as not counting for members as well.

A whole route a multi-day tour's stages were planned from is no longer
dropped on the server as soon as any stage has a day. Each entry carries
derived_from_place_id and the shared summary leaves the route out only
while one of its stages is done, counted and in the period and type. A
route walked last year now still counts when a stage of it is planned for
next month or was left out because it fell through, in the Atlas and over
MCP alike.

A tour on several days is dated on the last of them, as is a recording on
no day, so it no longer counts while it is still under way.

The info text, the get_tour_activity_stats description and the wiki say so.
2026-10-20 15:23:55 +02:00
Maurice bde2ad2ca8 feat(atlas): sum up the tours you have done, by period and type (STA-02)
With the Tours addon on, the Atlas panel gets a third tab, Tours, beside
Stats and Bucket List: what you have done on your tours across all your
trips. A period (all time, this year, the last 12 months or an earlier year)
and a tour type narrow it; the tab shows the distance large with the number
of tours at its corner, the ascent and descent, and the types with their
distance. "Show tours" opens every tour of the period in a dialog with the
same filters, the sums, the split by type and one row per tour.

What counts is said, never guessed. A tour counts once the day it is on is
over, with the values of the planning, badged "Planned" and explained as
not measured. A linked actual track (ACT-01) replaces those values for its
execution, badged "Recorded" and named, and is never added on top nor
counted as a tour of its own. A tour on several days counts once, a whole
route does not count beside the stages planned from it, a paddle tour sums
no heights, and a value nobody knows stays out of the sum, which then reads
"at least". Only the user's own part counts: a day names them among its
participants, or names nobody on a trip of their own; being able to open
somebody else's trip makes none of its tours theirs. Tours still ahead, on
no day or only on undated days are named as not counting. A tour that fell
through can be left out of the sums ("Leave out"): it stays in the plan
and in the list, dimmed, and "Count again" brings it back. Only the user's
own numbers change.

Server: GET /api/tours/activity-stats answers the user's activity entries
across every trip they can open, and PUT /api/tours/activity-stats/exclusions
leaves a tour out or counts it again (the tour must be in such a trip, else
404). A tour_activity_exclusions table keeps the exclusions per user and
tour. The sums are added up by a shared summary in @trek/shared, so the
Atlas and the assistant say the same numbers. MCP: get_tour_activity_stats
and set_tour_activity_excluded over the same service. The tab reads live
and needs a connection, like the rest of the Atlas statistics.
2026-10-20 15:23:55 +02:00
Maurice ae79ab8dad fix(tours): offer the map comparison only where the plan map can draw it (ACT-02)
The compare switch in a tour's detail showed whenever the detail sat beside
the plan map, but the map draws no comparison in road trip mode or with the
trip overview open. Pressing it there turned the switch on while nothing
appeared on the map.

The plan map now tells the compare store whether it can draw a comparison.
The detail offers the switch only then, and turning to the road trip or the
overview, or leaving the map, ends a running comparison.
2026-10-20 15:23:18 +02:00
Maurice 6a0c54cb2f feat(tours): compare a tour's plan with the track it was actually done with (ACT-02)
Each actual track in a tour's detail now stands beside the plan instead of
on its own: a table names the plan and the actual track as columns, each with
the line it is drawn as on the map, and sets distance, ascent, descent and the
time with breaks side by side. The difference is shown, signed and with an
arrow, only where both values are known and mean the same thing; a planned
total that leaves a stay out reads "at least" and is not subtracted from. A
value one side does not carry is named as missing ("Not in the plan", "Not in
the track", "No time stamps"), never estimated. Below the table, how far the
track left the planned line: its farthest point and the recorded stretches
more than 50 m away, or that there is no line to compare.

On the desktop plan map, "Compare on map" draws the actual track dotted in
the colour of done next to the plan, which stays picked in its own track
colour (and is put back should it be hidden on the map). The recording leaves
the map as a tour of its own meanwhile, so it is never drawn twice or in a
plan's colour. A frosted legend over the map names both lines and ends the
comparison; so do closing the detail, comparing another track, or the track
being unlinked. Comparing only reads: neither the plan nor the track changes.

The track is laid into the day's route lines the map already draws, so both
renderers show it without a layer of their own. distancesToLine in the shared
route corridor measures many points against one line laid flat once; a long
recording is measured in at most 1500 samples.
2026-10-20 15:23:18 +02:00
Maurice a8be1c5b45 fix(tours): keep unknown actual tracks unknown and name every track a link replaces (ACT-01)
A tour detail that cannot be read (offline without a cached copy, or a server
error) no longer shows "No actual track yet" while the trip list names tracks
of the tour; the section says they could not be loaded and offers no link over
them.

The day a track was recorded on is the viewer's calendar day of its first time
stamp, not the UTC one, in the tour detail, the link dialog and the GPX
preview alike, through one helper.

Taking a tour off a day keeps its actual track without a day, so "no day" can
hold several. A change now starts on the track it was asked for, and the link
dialog names every track it replaces, as the server clears all of them. The
migration, repository, service and MCP texts say so.

The refusal for linking a tour to itself or a track with tracks of its own now
reads "This track cannot be linked to this tour", which is true of both, and
the offline helper of the tour cache sits above the doc of replaceTripTours
again.
2026-10-20 15:23:10 +02:00
Maurice d0cc6fc2f4 feat(tours): link the track a tour was actually done with, and show its own values (ACT-01)
A planned tour can now be given the track it was actually done with. In the
tour's detail, "Add actual track" opens a dialog that asks for the day the
tour was done on (or none) and a recording of the trip: an import nobody
planned over, or a new one through the existing GPX import, its preview
preset to the tour's type. What the choice measures is shown before anything
is written, and only "Link" writes. The linked track keeps its own name and
source; the detail shows its distance, heights, profile and, only where the
file carried time stamps, the time it took, marked as actual values next to
the untouched plan. A track can be changed or unlinked again.

Server: a tour_activities table points a tour's execution (a day assignment,
or none) at a recording of the same trip, and the tours GPX import keeps the
first and last time of a track whose every point carries one. The values are
read from the recording's own line on every read, so a changed recording
updates them while the plan's line, measures and revision stay as they were.
Every check runs inside the write: the recording must be an import of this
trip not planned over, documenting no other tour; the day must be one of this
tour; a refusal names its reason and changes nothing. PUT and DELETE
/api/trips/:tripId/tours/:placeId/activities answer the detail, which now
carries the actual tracks; the list names them per tour and per recording.
MCP: assign_tour_track and unassign_tour_track over the same service and
checks, get_tour_plan reads the values without the line.

A recording linked as a tour's actual track is no longer counted as a tour of
its own in the trip's tour summary, and the GPX preview imports a city walk
as one instead of as a hike.
2026-10-20 15:22:55 +02:00
Maurice f016972a4e fix(tours): explain and offer to re-ask hike ways that could not be asked
A way OpenStreetMap could not be asked about this time now carries a tooltip
saying so, and the unknown list offers to ask again whenever such a row is
there, not only when nothing at all was answered.
2026-10-20 15:19:11 +02:00
Maurice c80152f41d fix(tours): keep a non-canonical sac_scale as written instead of lowercasing it
A value such as Alpine_Hiking is not one of the six SAC values word for
word, so it now reads as an unclear grade with the raw value OpenStreetMap
holds, rather than as a known T4 the planner normalised itself.
2026-10-20 15:19:03 +02:00
Maurice f745ca13c9 feat(tours): suggest round hikes of about a wished length from a stop (RTE-03)
A hike can now be planned as a round out of one of its stops without
building the loop point by point. "Round hike from here" in a stop's menu,
or the round button in the head of the waypoints for the start, opens a
card over the planner map: the start (any stop of the tour, picked from a
list), the wished length in the traveller's unit, the window of a fifth
either way it allows, and the tour's hiking limit, which every question
to the router keeps to together with the hike's preferences.

TREK looks for up to three different rounds itself on the hiking router it
already uses. It steers loops round circles in several directions from the
start, asks a loop that missed the length once more at a corrected size,
and learns from each answer how much longer the paths are than straight
lines. A loop that walks most of its ground twice (a way there and back), a
spot too far from any path, and a round that repeats one found are left
out, and nothing outside the window is offered. The rounds show up in
their colours on the map and in 3D as they are found, the map frames the
area they can lie in, and each lists its length, how far it lies off the
wish, climb, descent and walking time once the heights are in. Fewer
rounds than three, none, or a router that does not answer are said so.

Showing, choosing and closing change nothing. "Take this round" puts the
start, the steering spots as shaping points and the way back in place of
the plan as one step of the history, with a "Round taken" note and its
undo; a tour that held more than its start asks first. The round is then
an ordinary tour to edit and save. A start moved or a limit changed while
rounds are shown lets them go and the card says why.

The card shares its frame, buttons and rows with the comparison of a
section's ways, and the map corner still holds one card at a time.
2026-10-20 15:19:03 +02:00
Maurice 4f68029b2c fix(tours): keep one card in the map corner and offer only ways that change a section
While the other ways of a hike's section were compared, a click on a via
ferrata or a place on the map opened its card in the same corner, stacked
over the comparison. The corner now holds one card: whichever opened last
stays and the other closes, before paint, so the two never show together.

The router's first answer between the two ends is its own way under the
hike's rules, which the tour already routes the section by. Offered as a
choice, taking it only planned the leg again, usually to the same line, and
the card closed without "Way taken" or an undo step. It is no longer
offered, so every way taken is held by shaping points, changes the route and
can be taken back in one step.
2026-10-20 15:18:48 +02:00
Maurice 64445a5204 feat(tours): compare other ways for one section of a hike (RTE-02)
A routed section of a hike between two neighbouring points can now be
compared with the other ways the router knows between the same two points,
under the same limit and preferences. "Compare ways for the next leg" in a
waypoint's menu opens a card over the planner map: the way taken now and up
to three others, each drawn on the map in its own colour, with length,
climb, descent and walking time by the signposts' rule. Figures without
heights stay unknown, and the differences to the current way are shown
only where both are known. When the router knows no other way, or does not
answer, the card says so.

Showing, hovering and choosing change nothing. Only "Take this way" does:
the router is first asked whether a few shaping points hold the chosen way
(the check the road trip's picker already uses), and only then are they set
into that one leg as a single step of the history, with a "Way taken" note
and its undo at the foot of the map. Other legs, fixed lines, days and
points stay as they were, the totals follow from the replanned route, and
the shaping points can be dragged like any other. A section that changed
while the router was asked, or a way it does not keep to, is not written,
and the card says why. The ways are drawn in 3D too.
2026-10-20 15:18:38 +02:00
Maurice bff9bcde92 fix(tours): read ways past the grade cap as not asked instead of untagged
A long hike or mountain bike tour can run over more distinct ways than one
grade request may ask about. The ways past that cap were never asked, yet the
facets read them as carrying no surface, way type or grade in OpenStreetMap.
They now land under 'Could not be asked this time' with the unanswered ones.
2026-10-20 15:18:01 +02:00
Maurice 3572f4b8a3 feat(tours): show the known surfaces and way types along a hike (MAP-04)
The hike's "SAC grades" card becomes "Trail details": one switch, then a
segmented choice between SAC, Surface and Way type, three things kept
apart and drawn along the route on the planner map one at a time. Every
facet reads from the same answer: the ways under the line are matched on
foot once, their OpenStreetMap values fetched once through the
trail-grades read MAP-03 already uses, and each stretch is placed against
the tour's own line once. Switching facets asks nothing new.

The server now hands on a way's surface and highway tags as OpenStreetMap
wrote them, only trimmed and bounded. The planner groups them (asphalt or
concrete, paving stones, compacted, gravel, natural ground, grass, rock,
wood or metal, unpaved without a kind, other; path, footway, steps, farm
or forest track, cycle path, minor road, road, via ferrata, other), and
each row's tooltip names the values recorded, such as surface=gravel,
surface=pebblestone. The router's own surface reading is never used: it
is normalised and a default where nothing is recorded.

What is not known stays unknown and says why, in every facet: no value
recorded, a value no group takes or two at once (unclear, never the
nearest group), no way clearly under the tour's line, or a way that could
not be asked about. Imported tracks and hand-drawn stretches keep their
geometry; a matched way only counts where it keeps within 25 m of it.
A summary badge gives the known share and the most common class; the SAC
facet keeps its highest step and routing limit badges. Nothing about the
tour, its places or its days changes, and no routing preference is set.
2026-10-20 15:17:52 +02:00
Maurice ea9e997338 feat(tours): show the known SAC grades along a hike (MAP-03)
A hike in the planner, being edited or viewed as an import, now has an
"SAC grades" card. Switched on, it asks the router which OpenStreetMap
ways lie under the line (matched on foot, every grade of path open) and
the server reads their sac_scale through the Overpass mirrors, the same
trail-grades read the mountain bike grades use. The grades are drawn
along the route on the planner map in one ramp of a single hue, T1 to
T6, and listed by share and length; a row draws its class alone. Each
row names its source in a tooltip: the SAC hiking scale and the
sac_scale value OpenStreetMap wrote.

What is not known stays unknown and says why, never T1: a way without
a grade, a grade that names no single step (handed on as written), a
stretch with no way clearly under the tour's line, or a way that could
not be asked about. For imported tracks and stretches drawn by hand a
matched way only counts where it keeps within 25 m of the tour's own
line, so the original line is kept and nothing is guessed. The router's
own reading of the scale is not asked for, since it may be normalised.

The routing limit of a planned hike sits apart as a setting with its
own badge, and a known step above it is marked. Nothing about the tour,
its places or its days changes; switched off, the map is as it was.
No regional colour categories and no grade derived from slope, height
or trail markings.
2026-10-20 15:17:23 +02:00
Maurice ad2d653e9a fix(tours): save_tour_change says nothing was saved and answers a proposal saved once already
A save refused by the rules of the tour now says "Nothing was saved and
nothing changed." instead of the preparation's wording: lay() takes what
the changes are laid for, and refusalText() leads with that action.

A save sent again after it committed (a retry after a lost answer) used
to meet the conflict of a tour that moved on, which tells the assistant
to prepare the change anew, so an add could be saved twice. The save now
looks up the step its proposal was saved as from the revision it was
prepared on, before the stale check and once more inside the write, and
answers already_saved with that step instead, writing nothing.
2026-10-20 15:14:30 +02:00
Maurice b177daea3c fix(tours): point undo_tour_change at the revision prepare_tour_undo returns
The expected_revision field told assistants to read now.revision of the
preview, but prepare_tour_undo never returns that field: the revision sits
at undo.expected_revision. An assistant following the description either
guessed or left the value out and ran into a validation error or a revision
conflict. The description now names the real field, and the schema spec
pins it.
2026-10-20 15:14:30 +02:00
Maurice 63a7a4d397 feat(tours): undo_tour_change takes back a saved change to a tour once the traveller confirmed it
An assistant can now take back a change it saved with save_tour_change,
the way the change was saved: listed, shown, then taken back only after
the traveller said yes. Until now a saved step could be followed but not
undone; the traveller had to rebuild the walk by hand in the planner.

- list_tour_change_steps lists the steps of a tour, the latest first, with
  the changes as confirmed and where each stands: latest, undone, later
  change on top or no readable earlier state. Planner saves are no steps.
- prepare_tour_undo shows what taking a step back does without writing
  anything: the tour now, with the stations visited or skipped, set
  against the tour exactly as the step kept it, the stations kept, coming
  back and removed, and the distance and walking time stored then.
- undo_tour_change restores that tour whole or not at all, through the
  write the planner's save uses: line, stations with their keys and the
  measures stored then, in one transaction with the mark on the step, and
  the trip is told after the commit. It needs place editing on the trip
  and checks everything again inside the write.
- Only the tour's latest change goes back. A step something was saved on
  top of since, in the planner or by another step, is a conflict on the
  tour's one revision (no second one), so no later edit is lost unseen; a
  preview read at another revision is a conflict too. Taking back a step
  that would remove a station visited or skipped since, or bring back a
  station whose place of the trip is gone, is refused with every reason,
  and so is a step without a readable earlier state. Every refusal and
  failure leaves the tour as it is.
- A step is taken back once: tour_change_steps records the revision that
  raised the tour to, who confirmed it and when, and nothing else is kept.
- None of it touches a planner draft: the planner's own undo stays in the
  browser, and discarding a draft never changes a saved tour. The tool
  descriptions and the wiki say so.

The write of a tour's line and points and the tour as the answer gives it
now come from one place for the save and the undo.
2026-10-20 15:14:29 +02:00
Maurice f4533fc69b feat(tours): save_tour_change saves a confirmed change to a city walk whole or not at all
An assistant can now save the change prepare_tour_change prepared, once the
traveller said yes to it exactly as shown. Until now a proposal could only
be looked at; the traveller had to redo it by hand in the planner.

- The save names the proposal: the same changes, the revision it was
  prepared on, the proposal_id the preparation answered and confirmed true.
  Anything else, a change altered on the way or no yes, is refused and
  nothing is saved.
- Place editing on the trip and the tour are checked again before anything
  is written. A tour changed since the proposal was prepared is a conflict
  on the tour's one revision (no second one), telling the assistant to
  prepare the change anew instead of writing over newer edits. The changes
  are laid on the saved tour once more inside the write, so a station
  visited or skipped meanwhile, or a place of the trip gone, refuses the
  save with every reason.
- The change goes through the write the planner's save uses, in one
  transaction: line, stations, keys, measures and visits together or not at
  all, and the trip is told only after the commit. Stations kept keep their
  keys and visits. Where the router gives no way on foot the walk keeps its
  stations only and waits for the planner, as a created one does.
- Every save is kept as a step of the tour (tour_change_steps): the
  proposal, the revision it was prepared on and the one it raised the tour
  to, the changes and the tour as it was before them, so the step can be
  followed and taken back later. Nothing of the conversation is kept.
- A new walk has no revision to name and stays with create_city_walk; both
  tool descriptions and the wiki say which is which.
2026-10-20 15:14:29 +02:00
Maurice 1993ed4d28 fix(tours): prepare_tour_change drops the shaping of legs it takes apart and guards a loop's closing leg
A prepared change to a city walk took a station out or put one in without
touching the shaping points around it. The way after the change was then
routed through points steering legs that no longer exist, so the walking
effects showed a detour that is not real, and the points handed on for a
save could start or end on a shaping point.

- A shaping point belongs to the leg it steers. Removing, replacing or
  moving a station drops the shaping points of its legs in and out, and a
  stop put after a station drops those of the leg it splits. Shaping points
  of other legs stay. The answer names every dropped one in data_gaps
  (shaping_dropped), and the walk always starts and ends on a station.
- The closing leg of a loop counts as the leg into its first station. A
  closing leg drawn by hand now refuses a change to the first station and a
  stop after the last one, instead of quietly moving the end of a leg drawn
  in the planner. The hand-drawn check also covers every sub-leg between two
  stations, not only the one next to the station.
- for_later leaves out a new station already linked to a place of the trip,
  so following it no longer creates a second copy of that place.
2026-10-20 15:14:01 +02:00
Maurice 1969ca57d9 feat(tours): prepare_tour_change prepares a change to a saved city walk for review
An assistant can now prepare a change to a saved city walk and show the
traveller what it would do before anything is saved. Preparing writes
nothing: no tour, station, visit or day plan changes, and turning the
proposal down keeps the plan as it was.

- The changes apply in order and name the saved stations by their keys:
  add a station after another (a spontaneous stop is an add after the
  station the traveller is at), remove, replace and move. Stops put after
  the same station keep the order they were given in.
- What the travellers did stays as it is: a station visited or skipped on
  any day of the walk is never removed, replaced or moved, nothing goes
  before the last station already done, a protected station keeps its
  place and a leg drawn by hand stays as drawn. A proposal breaking a rule
  is refused whole, naming every change and why. Other tour types, and a
  walk that is a recorded track, are refused too: so far only a city walk
  takes prepared changes, since the others plan their way with routing of
  their own the server cannot run.
- The answer sets the walk after the change against the saved one: each
  station with what changed it and where it stood, what was removed, the
  way on foot before and after planned by the same router (the stored
  distance and walking time given apart, never mixed in), the stays as far
  as known, every leg with the new ones marked, the gaps that could not be
  worked out, a warning for a must station removed or replaced, and each
  new station as create_place takes it, to keep it for later as a place of
  the trip instead.
- The proposal names the revision it was prepared on, so a save of it is
  refused once the tour has moved on, and says whether the traveller may
  change the trip's places at all.

The way on foot of a new walk, its creation and a prepared change now come
from one CityWalkFootWayService instead of a copy in the briefing service.
2026-10-20 15:14:00 +02:00
Maurice b826ecd425 fix(tours): get_tour_plan calls the leg of a recorded track a track, not routed
An import without control points of its own answers the two ends of its
recorded track as its points, and the one leg between them was labelled
routed, as if the router had planned it. The assistant read a false fact.
Such a leg now says track, and the tool description and the wiki explain
the kind.
2026-10-20 15:14:00 +02:00
Maurice 19946039ad feat(tours): get_tour_plan reads the whole saved plan of a tour
An assistant reading a tour over MCP now gets everything the saved plan
holds, from the same reads the app uses, so its suggestions start from the
actual plan:

- legs lists every step from one control point to the next with how its
  line came to be (routed, via ferrata, drawn by hand, portage), keyed by
  the points' stable keys, and the length of a fixed leg's own line; a
  routed leg has no length stored apart and says so with null.
- line gives the saved line in brief: its point count, whether it carries
  heights, and a preview thinned to 200 points; null while the way waits
  to be planned.
- A city walk can be read for one of its days (dayId): each station then
  carries its visit status that day and run what is still open. Without a
  day no visit is told, so one day's ticks are never passed off as the
  walk's; dayId on another type or a day the walk is not on is refused.
- derived names every field worked out on reading rather than stored,
  including the control points an import without its own answers from the
  ends of its track.

Reading still writes nothing: no revision moves and no visit changes, and
drafts in a browser stay invisible. The wiki row of the tool says the same.
2026-10-20 15:13:41 +02:00
Maurice 6577595058 feat(tours): take a recorded ride from a GPX file as it was ridden
A ride recorded elsewhere comes in through the one Tours GPX import
(HOR-02): pick the file, choose Horse ride in the preview and confirm.
Once a ride is chosen, the preview says in three badges what the import
will and will not do for it: the line stays as the file recorded it, no
riding time is worked out, and nothing checks whether its ways may be
ridden. Each badge gives its reason in a tooltip, reachable by keyboard.
Nothing is stored before the confirmation.

Opened again, the ride's rail carries a Recorded ride card with the same
badges above its measures, and names heights the file did not carry
instead of showing them as zero. Its origin and file name stay in the
tour's provenance, it goes on a day like any tour, and no editable
version is planned from it on foot.

The import itself was already canonical and needed no change; end to end
tests now pin what this card promises: the stored line equals the
recorded points, the ride keeps its origin, distance and heights and has
no riding time, a file without heights keeps them unknown, a failed or
refused import leaves an existing ride and its place untouched, and the
same file twice is skipped.
2026-10-20 15:12:01 +02:00
Maurice 78366a9024 fix(tours): long rides save, the horse route store stays bounded
A ride sends its full line with heights and every horse route section's
own line besides, about fifty bytes a point, so a ride along a few
thousand points of bridleway hit the API's 100kb body ceiling and the
save failed with a 413. Creating and editing a tour are now measured
against TOUR_WRITE_MAX_BYTES (2 MB), like the book and list file
routes; every other route, the tour copy among them, keeps 100kb.

The horse routes kept in memory are now bounded by section points as
well as by count (64 answers, 500k points), so panning a dense map can
no longer grow the server by hundreds of megabytes.

A search that found no connection can be asked again on the view whose
routes are shown, and a ride with a recorded time shows the horseshoe
beside its riding time instead of footprints.
2026-10-20 15:11:25 +02:00
Maurice 0331d9950f feat(tours): plan rides on horseback from signposted horse routes
A tour can now be a ride (HOR-01). The planner offers "Horse ride" as a
tour type with its own horseshoe, and a Horse routes card beside the
waypoints looks, when asked, for the route=horse relations OpenStreetMap
knows in the map view. A route opens in a dialog that shows it on a map in
the sections it is made of, split at forks, gaps and crossings, so nothing
is ever joined across a gap. The rider picks a section and its direction,
sees what the horse=* tags along it say (open, limited, closed, or unknown
where untagged) and whether it is signposted the other way, and takes it as
one undoable step. A section that does not start where the ride ends is
only joined by a straight manual stretch once the rider switched that on.

Nothing of a ride is routed: every leg follows a section or is drawn by
hand, and the waypoint list and the map say which (a section as a solid
line with its route, a manual stretch dashed and marked unchecked). A point
at a section stays where the route has it, a ride works out no riding time,
and distance, heights, day assignment, GPX import and export, the 3D view
and the reversed ride keep working with each leg's origin saved and opened
again. A failed lookup leaves the ride and the routes already found as they
were.

The server reads the routes through the shared Overpass client
(GET /api/trips/:tripId/tours/horse-routes, Tours addon, trip access),
keeps an answer twelve hours per grid-aligned view, and the tour contract
refuses a routed leg, a via ferrata or a riding time on a ride and a horse
route section on any other type. A migration enables the horse tour type.
2026-10-20 15:11:08 +02:00
Maurice dfe11e9c4c fix(tours): give the snowboard a corner glyph of its own
Snowboarding used the snowflake, the same glyph the winter hike label
shows in the same corner badge, so a snowboard track looked like a winter
hike in lists and the day plan. It now draws a board with its bindings.
2026-10-20 15:06:06 +02:00
Maurice fcdb7e48cd fix(tours): start the winter label from winter hiking on every other tour
The winter field remembered the label the switch brings back in its own
state, seeded once on mount. The details section stays mounted while the
planner saves, closes or opens another tour, so a snowshoe hike left its
choice behind and the next unrelated hike switched on as snowshoeing.

The planner now keeps that choice beside the label itself: a label set is
remembered, starting a new tour or discarding resets it to winter hiking,
and opening a tour takes the tour's own label.
2026-10-20 15:06:06 +02:00
Maurice 008aaeb408 fix(tours): say to zoom in when the pistes are on further out than they draw
The pistes overlay has no tiles below zoom 9, so switching it on at a region
or country view left the map empty while the switch looked on, which read as
broken. Both maps now report their zoom with the viewport, on the 256 px tile
scale raster sources count in (a GL map's own zoom is one step lower there),
and the planner hands it to its overlays. While the pistes are on and the map
is further out than their first tiles, the switch stays pressed in the soft
accent, its tooltip and the key's first line say to zoom in, and the screen
reader hears it once. An unknown zoom claims nothing.
2026-10-20 15:06:05 +02:00
Maurice 5ff7e54e09 feat(tours): lay pistes, cross-country trails and lifts over the planner map
SKI-01. The Tours planner's layer switch gains a winter layer: OpenSnowMap's
transparent piste overlay over whichever base map is shown, so a ski area
can be looked at while preparing a winter trip. It is offered with no tour
open (a resort without any route), beside a hike for winter hiking and on an
imported ski track; it needs neither a GPX import nor the ski type, and a
bike tour, a run and a paddle tour do not offer it. It is off until switched
on, and the choice belongs to the map view: switching it changes no point,
no step and no route, and it stays on from no tour to a hike and into 3D,
where the terrain draws it too.

The switch goes by its icon alone, named by its tooltip, so a hike's row
stays short. While the layer shows, a small key button rides beside it in
the same pill, its badge the four grade colours. The key opens above the
map as a popover: the downhill grades in OpenSnowMap's own colours (novice,
easy, intermediate, advanced, expert, freeride), cross-country trails,
winter and snowshoe trails, toboggan runs and lifts, a note that this is
mapped data that says nothing about what is open or groomed, the snow or
avalanches, and a link to the full key at OpenSnowMap. If the tiles stop
coming, the switch turns to the warning tone with its badge and says that
the map and the route still work, and the key stays away.

Only the overlay is used, never OpenSnowMap's relief or base maps. Like
every overlay it loads only the tiles in view: nothing is prefetched or
cached by the Service Worker, as their terms forbid bulk downloads. The
tiles go out with the page's origin as the referer, the attribution credits
OpenSnowMap (CC BY-SA) and the OpenStreetMap contributors, and the layer
stops at zoom 18, beyond which the server answers a picture instead of a
transparent tile. The CSP admits tiles.opensnowmap.org for the GL map,
which reads tiles through fetch.
2026-10-20 15:06:03 +02:00
Maurice 0169be3d79 feat(tours): import a ski track from GPX and keep it as recorded
SKI-02. Ski joins the types a GPX file can be imported as, offered in the
import preview's type field only: TREK routes no ski track, so the planner
never offers it. The preview asks for the discipline on purpose (alpine,
snowboard, cross-country or ski touring, none chosen at first) and, for
cross-country, the technique (classic, skating or unknown); Import waits
until both are named, and the technique stays in sight but dimmed for the
other disciplines. The track is stored exactly as recorded, with its file
name as provenance, its distance and heights from the file and no time:
no ski time is worked out and no hiking estimate stands in for one.

Opened in the planner, a ski track gets a rail card with its discipline on
a tinted tile, a cross-country technique as a badge beside it, the ski time
said as not worked out, and an info dot on what TREK does not say (snow,
avalanches, open runs, and that the 3D gradient colours are no difficulty
or avalanche rating). Change opens a dialog to classify the track again;
only the classification changes, through the offline core, with the
revision it was made at. Planning over the import is not offered for ski.
The existing 3D terrain view, the elevation profile and its focus work on
the track as for any import, with the standard map, topo and satellite and
no via ferratas. Lists, the day plan and the detail show the discipline as
a frosted corner badge on the tile and as a pill with the technique inside;
day assignment, copies and take-overs work as for any tour. The corner
badge is now one component that the winter label shares.

The server keeps tours.ski_discipline and tours.ski_technique (CHECK lists,
NULL on every other type), switches the ski type on without a routing
profile, refuses a ski import without its discipline, a technique outside
cross-country and ski fields on any other type, and takes a planned save's
classification off. PUT /api/trips/:tripId/tours/:placeId/ski classifies a
ski track again (place_edit, 409 on a stale revision, 400 for a tour that
is no ski track) and broadcasts tours:changed. GPX export names the
discipline (alpine_skiing, snowboarding, cross_country_skiing,
backcountry_skiing), and list_tours describes both fields with what a ski
track does not say. Ski is labelled plainly as Ski in every language, so
it no longer reads as ski touring.
2026-10-20 15:06:01 +02:00
Maurice 68907ec811 fix(tours): name the path grades on the run card without dashes
The run card's grade rows and its hardest-grade badge borrowed the hiking
limit's labels, which read 'T1 — Hiking'. The card now has its own labels in
the bike card's style ('T1, hiking') in every language, and the hiking limit
keeps its own wording.
2026-10-20 15:06:00 +02:00
Maurice 51bfb5abd1 feat(tours): plan a trail run on forest tracks and natural paths
RUN-02. Trail running joins Running as a run profile, chosen on purpose in
the planner and in the GPX import. It asks Valhalla's pedestrian costing for
tracks wherever they serve (use_tracks 1) and admits mountain paths up to T2
(max_hiking_difficulty 2), never T3 and above, with ferries only where no
way on land exists. Checked with trace_attributes against the public
instance: above Freiburg Guenterstal up to the Schauinsland the route went
from 82 % roads and 83 % paved (running) to 78 % forest tracks and 81 %
compacted, gravel and dirt; Rathen to the Bastei took 28 % of T2 path that
running leaves out; Heidelberg's old town stayed as it was. Leaning on
footways (walkway_factor) was tried and left out, it moved runs onto paved
footways.

While a run is planned, a card says what it runs on: its surfaces, the
hiking grades of its paths (sac_scale, T1 to T6, as mappers gave them) and
its kinds of way, each as shares with lengths that point at their stretches
on the map. A way without a grade or a surface is its own row, unknown and
never easy, and an info dot says the grades say nothing about whether a way
can be run. The head carries the unpaved share and the hardest graded path
as badges. The line is matched on foot for this, so steps and mountain
paths stay on it; a bike tour's is matched as before. Reading fails, goes
offline or is retried as for a bike tour, and nothing of it changes the run.

Pace and time are RUN-01's: the running time stays the distance at the
runner's pace, with climbs, descents and difficulty left out, whatever the
profile. A profile change plans the route again and keeps the pace; a
failed trail route keeps the last route and holds Save back. The card's
building blocks moved out of the bike card into RouteWaySections, so both
cards share one set of rows, bars and status lines.

The run_profile column already allowed trail (RUN-01), so no migration.
GPX export names a trail run trail_running, list_tours describes the
profile as a routing preference, never a statement that a way can be run.
2026-10-20 15:05:51 +02:00
Maurice 1d9b9bf838 fix(tours): read a run pace the way runners write it, and keep it
A pace typed as "5.30" or "5,45" is now read as 5:30 and 5:45, the way
it is written in much of Europe, instead of quietly becoming 5:18 and
5:27. One digit after the point stays a tenth of a minute ("5,5" is
5:30); anything else is marked as no pace.

The pace field no longer flashes the out-of-bounds error while a pace
is still being typed: the first digit of "12:30" reads as 1:00, so a
pace outside the bounds now reaches the planner only on leaving the
field or pressing Enter, like text that is no pace already did.

The pace unit is translated ("мин/км", "分/km" and so on) in the field,
the sum the running time is and the pace pills, so what is shown
matches what assistive tech reads out.

A run planned over in place again keeps the pace it holds, so the
runner need not type it again before Save.
2026-10-20 15:05:45 +02:00
Maurice c83eb42730 feat(tours): plan a run at your own pace
RUN-01. Run joins the tour types in the planner, with Running as its first
profile, chosen on purpose like a bike tour's. A run is routed on foot for
runnable, mostly paved ways: tracks and ferries only where there is no other
way (use_tracks 0, use_ferry 0, measured against the public Valhalla), no
hiking limit and no via ferrata. The runner types a pace in min/km; the
running time is the distance at that pace and nothing else, shown under the
field as badges (8 km x 6:00 /km = 48 min). TREK suggests no pace: without
one the time stays unknown and Save waits, text that is no pace or a pace
outside 2:00 to 20:00 is marked. The info dot, the overview and the head say
climbs, descents and difficulty are not in the time, and the time reads as
planned everywhere, so it never passes for a recorded one; the heights are
shown as for any tour on land.

Type, profile, pace and route come back on opening, survive a draft reload,
an undo of the type, a copy and a take-over; switching to another type drops
profile and pace. Day assignment, the 3D terrain view (with topo and
satellite, no ferratas) and a routing failure keeping the last route work as
for every tour. A GPX file can be imported as a run with its profile and no
pace, and planned over as a run. Runs export to GPX as running and get their
own places along the way (water, toilets, parks, viewpoints).

The server keeps tours.run_profile (CHECK list, already holding trail for
RUN-02, since SQLite cannot widen a CHECK in place) and
tours.run_pace_seconds_per_km (120 to 1200), switches the run type on,
refuses a run without a profile and run fields on any other type, and
list_tours names both with what the planned time does not include.
2026-10-20 15:05:38 +02:00
Maurice cd407cf80c feat(tours): a hike can be marked as a winter hike or a snowshoe hike
HIK-01. The details of the hiking planner gain an optional Winter activity
switch, off by default, so the normal hiking flow asks for nothing more.
Switched on, the hike is a winter hike or a snowshoe hike; the choice stays
dimmed and out of reach while the switch is off. The label is saved with the
tour, comes back when it is opened, survives a draft reload, a copy and a
take-over, and switching it off takes only the label away: the route, its
grade and the days stay as they were.

The label is a label only. Routing, the walking time and the 3D terrain view
work as for any hike, and the info dot and the head pill's tooltip say that
nothing was checked for snow, avalanches or whether a path can be walked in
winter. The saved label shows as a frosted snowflake on the corner of the
tour's tile in the tour list and the day plan, and as a pill beside the type
in the tour's detail.

The server keeps it in tours.winter_activity (CHECK list, NULL for every tour
saved before and for every other type), refuses it on a bike tour, a city walk
and a paddle tour, and keeps the stored label when an older client saves a
hike without the field. A snowshoe hike exports to GPX as snowshoeing.
list_tours names the field, with the same words on what it does not say.
2026-10-20 15:05:15 +02:00
Maurice 71fe69c1f1 feat(tours): mark the best known places along a city walk
A search for sights along a tour now also asks the places index for the best
known places near the line, ranked by how often their Wikipedia articles are
read (QRank). A row the index already found is marked as a highlight; a
famous sight no kind names, like a square, is added as a sight of its own.
Stations, hotels and restaurants rank a row they match but are never added.

Highlights are listed first with a star in the list and a badge in the map
card. A place counts as a highlight from 40000 views a year, or the best
three from 5000 where fewer reach that. An index without ranks, or one older
than the endpoint, leaves the search as it was.
2026-10-20 12:16:11 +02:00
Maurice 54baf85a2a fix(tours): a city walk keeps to the bridges and avoids ferries
The walk was routed with the pedestrian router's default, which takes
ferries, so a walk through Venice went along the vaporetto lines across
the canals instead of over the bridges. A walk now asks for no ferries
(use_ferry 0), as a hike that avoids them does; where no way on land
exists the router still takes the ferry.
2026-10-20 11:32:03 +02:00
Maurice 3882feec5c fix(tours): the last review findings of the city walks
A place closing at midnight is no longer read as open past it: the hours
run on only where the next date opens at midnight (any window of the day
counts, as written order is kept), and an impossible date such as
2026-02-31 reads as unknown instead of rolling into March. The hours
source names the trip's places rather than "your own" entries, in all
27 locales, as the pill beside it does.

The day a walk's arrivals are checked for shows its date on the pill,
not only "Day N". The 12 hour clock is tested for a window to midnight.

The along-the-tour term table is built once again, now that the one test
that stubbed the places client without its real exports spreads them.
New test seeding goes through the ORM factories.
2026-10-20 10:37:29 +02:00
Maurice 6477bf5e39 test(admin): fail the addon load with a server error, not a dropped connection
A network error starts the client's connectivity probe, and whether the
app then counted itself offline, where it stays quiet on purpose, raced
the error toast on a loaded runner (FE-MOB-AADD-004 failed in CI twice).
A 500 fails the load the same way and toasts every time.
2026-10-20 10:12:36 +02:00
Maurice 971133a1eb test(tours): read what taking a place in did from the hook test, not from a planner whose points stay put
The card test's planner is a stub whose waypoints never take the new
point, so the list's outcome line, which waits for the point to be in
the route, cannot show there. FE-TOUR-POI-006 covers it with a planner
that does.
2026-10-20 10:01:08 +02:00
Maurice 0344db6d0c test(shared): count the tour keys without the four hint lines taken out
The stay-unknown pill, the along-the-tour intro, its undo hint and the own
theme line left the tours domain in the previous commit.
2026-10-20 09:49:27 +02:00
Maurice 5d265c98de feat(tours): places along the tour picked on the map, stops that keep their kind, and calmer station rows
Along the tour (PLN-02, PLN-03): the list shows one line per place (its
icon, name and distance from the route) and scrolls past ten. A place
picked in the list or on the map opens its card over the map, where the
other cards of the planner sit, with what the source says and the way to
take it in as a stop. What taking it in did is one line in the list. The
intro, the theme line and the out-of-date note are gone: the themes say
what they look for on hover, and the search button says when to search
again.

A stop taken in from there keeps the kind of place it was found as
(migration 045400, poi_category, read back only where the contract knows
the kind) and its number carries that kind's icon at the corner, as a
via ferrata's end does.

City walks (CW-02, CW-03, CW-10): a stay nobody entered says nothing.
While no stay is entered at all the walk is planned as its walking time,
with no "unknown" and no "at least". A must-see rings its number, an
optional stop has a dashed ring, a protected one a shield at the corner;
only the stay stays under the name, below the coordinates.
2026-10-20 09:47:24 +02:00
Maurice 554d6f1b9f feat(tours): a city walk prepared from a briefing over MCP, and the station fields an assistant reads spelled out (CW-10, CW-11)
An assistant connected over MCP can now prepare a city walk from what the
traveller tells it. propose_city_walk takes a start (and an end), a time
frame on a day of the trip, the themes of CW-05, the stations the
traveller wishes for, the places of the trip it may use and fixed
appointments with their times, and saves nothing. Places for the themes
come from the search along a tour, at most eight, each with the theme,
category and source that brought it; nothing is made up. The stations are
ordered on estimates over the straight distance, and a place of a theme or
of the trip goes in only where it is open on arrival, keeps the frame and
the appointments, and leaves every station already on the walk open when
the walk gets there. The walk in that order is then planned on foot by the
router the road trip uses (the traveller's own routing server where they
set one), and its legs give the walking distance and time from one station
to the next. Where the router cannot be reached, or a station lies more
than 300 m off every way (the planner's limit, now shared with the
server), the times are estimates over the straight distance with an
allowance for streets at 4.5 km/h, and the answer says so and why. The
answer gives the arrival and leave times, the stay where the traveller
gave it and unknown otherwise, which makes every later arrival the
earliest one, and the opening hours checked against the arrival on that
day with the helpers the planner uses. Walking and stays are counted
apart, the total is complete only when every stay is known, and the
minutes the frame leaves for the unknown stays are named. Free time before
an appointment or at the end, wishes and appointments that cannot be kept
as asked, and places left out with their reason are listed; a tour of the
trip offered as a station is named as a tour. A wish or an appointment is
never dropped; an appointment is a must and protected. A place's default
duration is not read as a stay: every place carries one, so it says
nothing.

Once the traveller confirms, create_city_walk saves the walk through the
same creation the planner's save uses: one write, a key for every station,
the links to the trip's places checked, the trip told, with place editing
permission on the trip. Its way on foot is planned by the same router and
saved with the walk, together with its distance and walking time. Where
the router gives no way, the walk is saved with its stations only and the
answer says why: no line, no distance and no walking time. The tours list
says so (route_pending), the tour detail says in one line that the way is
planned in the planner, and opening the walk in the planner plans its way
at once and offers to save it, with a note once it is planned that it was
just planned. Such a walk can be saved as a new tour and taken over into
another trip, starting at its first stop, and is never offered as a place
to stay. A tour without a line needs two points of its own.

get_tour_plan now spells out every field of a city walk's stations: key,
name, stay (null when not known), importance (null for normal), protection
(false when not set) and opening hours (null when none are known);
list_tours names the station count and whether a walk waits for its way,
get_city_walk_progress gives each station's key and opening hours, and
take_over_tour says a walk keeps its stations with their protection. No
tool changes a walk's stations; deleting a walk's place takes its stations
along, protected ones included, and both delete tools now ask the
assistant to name those and ask the traveller first. REST answers the
stations as before.

A tour create refused for a type that is switched off, a description that
is too long or planned times out of range is a domain refusal now, so an
assistant gets the same text REST sends; REST still answers it as an error
at 400.
2026-10-20 07:09:57 +00:00
Maurice f79b2020d6 test(admin): wait for the addon list's empty state after a failed load
The empty state shows once loading has ended, which can come a render after
the toast; the test read it in the same tick and failed now and then.
2026-10-20 07:09:57 +00:00
Maurice 9f0caf8539 feat(tours): public transit between two city walks in the day plan, from the first walk's end at the time it ends (CW-08)
"Public transit" on the connector between two city walks in the day plan,
on the desktop and on the phone, now opens the existing search from where
the first walk really ends to where the next one starts, at the
coordinates the day's route draws, named after the first walk's last stop
and the next walk's first stop. A loop ends at its first stop again. A stop
without a name takes the name of the trip place it stands for, and a walk
whose stops cannot be read on this device names that end "End of" or
"Start of" the walk. The search opens on the walks' day and departs when
the first walk ends: its start that day, the time on the day's entry
before the place's own, plus its planned total, a total entered by hand
before walking and stays. The assignment's own end time is not used.

Above the search a note says when the first walk ends, "at the earliest"
where a stay or the walking time is not known yet, and when the next walk
starts. Without a start or a total no time is made up: the time field
stays empty and Search waits until one is chosen. A walk that only ends
after midnight says so, and the time field stays empty as well. On any other day, or for
any other leg, the search opens as before, with 09:00 as its default. A
connection that arrives after the next walk starts says so in its card,
before its legs are opened and before it is taken. Nothing is saved until
"Add to day", which keeps the connection as its own journey in the plan as
before; both walks stay two walks on foot and nothing is moved.

A journey saved before a walk that now arrives after the walk starts says
so on its row in the plan, on the desktop and the phone, while the plan is
looked at. Nothing is rewritten. The desktop no longer draws a connector of
nothing between a stop and a booking at the same spot, as the phone already
did, so a journey saved at the walks' ends adds no walk on either side. A
leg leaving a loop tour no longer departs at the tour's start time.

The leg is told by the walk it leaves, so two walks of a day that end at
one spot, such as two loops from the hotel, each search from their own end
and time, and a stop between two walks keeps the leg an ordinary one. Only
the leg asked for last opens the search, and none opens once the plan is
left while the walks' stops are still being read.

Both views run on one shared hook, the walk's stops are read through one
helper the stop list uses as well, and the walk's start on a day is worked
out in one place for the times shown in the detail and here.
2026-10-20 07:09:57 +00:00
Maurice 7470a2c6d4 fix(tours): a short link whose name does not resolve just now, or that Google does not know, is said as such in the walk import (CW-06, CW-07)
When the name of maps.app.goo.gl or goo.gl could not be looked up for a
moment, the walk import said the short link leads somewhere other than
Google Maps. It now says the link could not be followed just now and answers
502, as for any other failure on the way; a name that leads into this
network is still refused.

A short link Google answers with 404 or 410 is one it does not know, and
asking again changes nothing. The dialog now says so in the reader's
language and asks to share the route from Google Maps again, with a 400 and
the reason short_link_unknown, instead of suggesting to try again later. A
busy or blocking answer from Google stays a failure that may pass.

The MCP tool preview_google_maps_walk is marked open world, since it follows
a short link to Google on every call, and the English text REST and MCP send
for each reason is held to the English text the planner shows.
2026-10-20 07:09:57 +00:00
Maurice 4ca5728072 feat(tours): a city walk from a Google Maps walking route, its short link followed only to Google Maps (CW-06, CW-07)
"Add tour" in the planner now offers "Import walk from Google Maps". The
dialog takes the link of a route shared in Google Maps, either a
maps.app.goo.gl short link or the long google.com/maps/dir link, or the
share text of the Google Maps app with the one link in it. It shows the
stops in their order with their names, marks a stop the link gives no
position as such, says that the route comes from Google Maps with a link to
open it there, and that TREK plans the way between the stops itself and
does not take the line drawn in Google Maps over. Nothing is written until
"Load as walk": that opens a new, unsaved city walk in the planner with the
positioned stops and their names, routed on foot by TREK, and warns first
when it replaces unsaved work. A stop without a position is left out and
never looked up or guessed; with fewer than two positions nothing can be
loaded. While the walk is not saved, a calm notice in the planner says
where it came from, also after a reload of the draft. Cancelling or a
failure leaves every tour as it was.

Only a route the link marks unambiguously as on foot is read. A car, bike
or transit route, a link that does not say, or one that says two things is
refused with a reason the dialog gives in the reader's language. Each stop
takes its position from its own block of the link's data, so a line
dragged through extra points or a stop given as coordinates no longer
leaves the stops without positions or shifts them. The road trip and
places imports read links as before.

A short link is followed by a resolver of its own: every hop is checked
against the Google Maps short link shapes and the strict SSRF guard before
it is looked up or asked, so ALLOW_INTERNAL_NETWORK and
ALLOW_LINK_LOCAL_IPS do not open it. Each request goes to the vetted
addresses only, follows nothing by itself, reads no body and carries no
credentials; the chain has one deadline including name lookups, at most
three hops and a cap on the Location header, and the long link it ends on
must be Google Maps and is never fetched. The preview is POST
/api/trips/:tripId/tours/import/google-maps/preview for members who may
edit places, with a 502 when Google could not be reached, and the MCP tool
preview_google_maps_walk reads the same.
2026-10-20 07:09:57 +00:00
Maurice 2203ff3a2e feat(tours): a city walk's arrivals and opening hours for the day it is on, at each stop in the planner and the detail (CW-02, CW-04)
A city walk now shows, at each stop, when the walk gets there, until when
the stay lasts, and what the stop's opening hours say about that visit. The
same lines appear under each stop row in the planner and under each stop in
the walk's detail, in the user's 12 or 24 hour format. A conflict names the
stop, the arrival, the end of the stay and the hours it was checked against,
for instance "Lenbachhaus closes at 18:00, during the stay from 17:20 to
18:20 (open 10:00 to 18:00)", in the warning colour. Nothing is changed by
these hints: no stop, order or route moves.

The times belong to one day the walk is on. Opened from a day of the plan,
the detail keeps that day, and checking stops off stays bound to it.
Opened otherwise, and in the planner, a small pill above the stops names
the day and its start, and the walk's other days can be picked from it; it
starts on the first day with a start time. The pick is only what is looked
at and is never saved, and another day never takes over the times quietly.
Without a day, a date or a start time the line says what is missing and no
arrival is shown. TREK keeps no time zone on a trip, so start, arrivals and
opening hours are all read as the local time of the plan, and an arrival
after midnight is said to be on the next day without being checked.

Opening hours come from the trip place a stop stands for, as the travellers
entered them, and otherwise from OpenStreetMap; a pill at each stop names
the source, and a notice says the hours are a hint to check. A stop without
hours stays unknown and is never shown as open, hours that are not in a
plain form are shown as written, and an arrival that is only a lower bound,
because a stay before it is not entered yet, is not judged unless no later
arrival could escape the conflict. A trip place that a stop stands for also
gives the stop its name before "Stop N".

The arrival at a stop is found where the route passes it in the walk's
order, so the end of a loop and a stop passed on the way out and back get
the right time. The rules live once in the shared package, for the server
to give the same answers. The walk's detail reads its stops again after a
save, its own or a teammate's.
2026-10-20 07:09:57 +00:00
Maurice 0d7e3ceb3d fix(maps): read the index terms when a row is judged, not when the module loads (PLN-02)
A suite that stubs the places index client without its term table could no
longer load the maps service, because the list of every term was built when
the module was first imported. It is read where a row is judged now.
2026-10-20 07:09:57 +00:00
Maurice 93a508a0e6 fix(tours): count a city walk's visits event in the realtime inventory (CW-03)
The registry of realtime events gained tours:visits when a city walk's
stops could be checked off for every member of the trip, but the test that
pins the inventory still counted 76 trip events and 109 in all. It now
counts 77 and 110 and says which event the 77th is.
2026-10-20 07:09:57 +00:00
Maurice 0b92dcc86b feat(tours): show a city walk's progress on the phone's day row, and check its stops off offline (CW-03)
The day row on the phone now shows how many stops of a city walk were
visited on that day, "2 of 5 visited", like the desktop row does. Both rows
read it from one place, and a tap on the phone names it in full without
opening the row.

A walk's stops can be checked off, skipped and taken back without a
connection. The mark shows at once, waits in the offline queue and goes out
when the connection is back, in the order the marks were made and behind any
older mark of the same day that still waits. The day plan, the walk's detail
and a reload read the visits kept on this device with the waiting marks on
top, so a mark made offline stays on screen until the server has it. A mark
the server answers with "not found", because the stop, the day or the walk
was removed meanwhile, counts as done and holds back no later mark of that
day. Any other refusal, the Tours addon turned off among them, keeps the mark
as a failed change to try again from the offline settings. The visits are
read again when the connection comes and goes, and once more after the
waiting marks went out, so a mark the server dropped or refused leaves the
day plan. A trip's visits that answer after another trip was opened no longer
show there.

The offline sync keeps each trip's walk visits and reads the stops of a walk
this device has never opened, so it can be checked off offline too. A walk
whose stops this device could not read lists no stand-in stops and no count,
and says to open it again while online. A walk put on a day while offline
says its stops can be checked off once it has synced, instead of offering
buttons that cannot work yet. A mark that fails says to open the walk again
and try once more, in every language.

Marks carry no version, so the last one to reach the server wins, as with
the other changes sent after being offline. The offline guide says so.
2026-10-20 07:09:57 +00:00
Maurice c836ee3988 fix(tours): a city walk saved offline shows its stays and total at once, and the planner plans the total its overview shows (CW-02)
A city walk created or changed while offline now carries its stops, the
minutes its stays are entered with and how many have none yet, worked out
from its points the way the server will answer them. The day row, the tour
detail and the summary show the walking time with the stays as soon as the
walk is saved, with "at least" where a stay is missing, and a stay changed
while the write waits shows its new total rather than the one of the last
answer. The server and the client count the stops in one shared place.

The planner plans a walk whose walking time is not known yet with at least
the stays entered, as its overview says, so the planned field and the check
against the longest plan read the same total.

In the tour detail a longer time such as an "at least" total wraps on a
phone instead of being cut, and the boxes of a row keep one height. A total
entered by hand falls back to its short name where its column is too narrow.
2026-10-20 07:09:57 +00:00
Maurice 188953158a fix(tours): a city walk's walking time, stays and total read apart and alike everywhere, and a stay plans no route again (CW-02)
A stay, a name, an importance or a protection set in a stop's dialog no
longer plans a city walk's route again: the walk stays ready to save, its
time overview follows at once, and one undo takes the change back without
routing either. A stop moved or added plans the route again as before. A
walk saves no breaks, so a cafe never counts as a stay and as a break at
once, and a break left from another type neither counts nor holds its save
up.

The planner and the tour detail show a walk's walking time, its stays and
their total apart and in the same words: 45 minutes of walking, a museum of
90 minutes and a cafe of 30 minutes make 2 h 45 min. A stay not entered yet
makes the stays and the total at least what is known, stays nobody entered
read not known yet, and stays entered as 0 read 0 min. A total entered by
hand keeps its own label, and the computed total shows beside it. The head
of the tour detail, the day row on the desktop and the phone, the tour
summary and the library of other trips show that same total, with a ≥ where
a stay is missing. Their time on the move is called walking, and the summary
says a walk is planned with its stays rather than breaks.

The tour list and the library carry the minutes a walk's stops are entered
with and how many have no stay yet, counted from the walk's points, so the
way back of a loop is no stop and its start's stay counts once. A loop run
the other way keeps its start as the stop there, with its key, name and
stay.

The planner's waypoint list keeps the rows of a leg in a file of their own,
and the tour detail its name heading in a part of its own, so both stay
within their size limits.
2026-10-20 07:09:57 +00:00
Maurice 20f8266439 fix(tours): keep a city walk's visits through a save from the planner, and count a loop's start once (CW-02, CW-03, CW-09)
A walk saved from the planner kept its stops without the keys the server
gave them, so the next save from the same open planner handed every stop a
new key and dropped the visits checked off in the day plan meanwhile. The
planner now takes over the keys a save answers, point by point, without
planning the route again, so a stop keeps its key and its visits from one
save to the next.

The edits of a stop's dialog now reach the planner: a changed name, stay,
importance or protection counted as no change and was dropped. It is a step
of its own that can be undone, Save stays available, and the route is not
planned again for it.

A save that takes stops out of a walk, or makes it another type, tells every
client of the trip which visits are left on each day the walk is on, the
client that saved included, so the progress in the day row no longer counts
a stop the walk has lost.

The way back of a loop is the start again and no stop of its own. Closing a
loop gives it only the start's spot and the place it stands for, and the
planner's stays, the stops of the tour detail, the stop count of the tour
list, the visits and the progress an assistant reads leave it out, so a loop
through three stops counts three, the start's stay counts once and the way
back cannot be checked off on its own. A loop saved before keeps working.
The rule lives in one helper the server and the client share, and the tour
list takes its stop count from the walk's points through it.

The tests that seed a walk for the assistant's progress go through the ORM
factories.
2026-10-20 07:09:57 +00:00
Maurice e5f01e570a fix(tours): check a city walk's stops off and save a stop from its dialog, with the walk under test (CW-01 to CW-05, CW-09)
Marking a stop of a city walk visited or skipped answered with a server
error every time: the write that keeps a changed status stamped its time as
a function call, which neither SQLite nor Postgres takes. It now uses the
clock the database itself spells, so a stop checked off, skipped or taken
back stays that way and the day plan of everyone on the trip follows.

The Save button of a stop's dialog in the planner did nothing, because it
sat outside the dialog's form. It saves the name, stay, importance and
protection again. A walk saved with two points that carry one key, as a
loop back to the start does, keeps that key on the first point and gives
the other one of its own, so checking one off never marks both. A walk put
on one day twice is read for an assistant from its first assignment.

The server tests cover the visits from the repository to the routes: per
day, through a moved and a removed assignment, a copy of the walk and a
copied trip without visits, a removed stop taking its visits along, and
the refusals for another trip, a stop the walk does not have, a member who
may not edit days and an unknown key or body. The MCP progress of a walk,
the station fields and counts of the tour list, and the contracts in
shared are tested too; the existing tests expect the keys every saved
point now carries, the station count of a list row and the new entity.
The client tests cover the stop dialog and its pills, the walk themes and
categories of the search along a tour, the stop menu of the waypoint list,
the walk's timing, routing and type choice, and answer the trip planner's
read of the visits in the tests instead of leaving it unanswered.
2026-10-20 07:09:56 +00:00
Maurice 2a8dbf91a8 fix(tours): keep the city walk helpers only their own files use inside them (CW-01, CW-03, CW-04)
The filter for a walk's stations in the planner, the opening windows of a
walk's schedule and the station shape of a walk's progress were exported,
although nothing outside their own file reads them, so the check for unused
exports counted three more than it allows. They are local to their files now;
a city walk plans, checks off and times its stops as before.
2026-10-20 07:09:56 +00:00
Maurice ec9e1a7533 fix(tours): name the Overpass kinds of a search along a tour at once while every mirror is down (PLN-02, CW-05)
While the public Overpass mirrors do not answer, every search along a hike
with viewpoints, huts or drinking water waited out the whole twelve second
deadline only to name those kinds as not searchable. Once every mirror has
failed the search along a line, and no other search along a line was
answered in the meantime, the next searches for three minutes leave Overpass
alone and name its kinds at once, next to the places the index found; a later
stretch of a long line that was already being searched does not wait either.
A stretch answered shortly before keeps its places from the cache during the
pause. After the three minutes the next search asks again for real, and any
answer ends the pause. The log holds one line per pause with the time it
ends, next to the line of the search that failed. The explore buttons of the
map and the other Overpass lookups are unchanged.
2026-10-20 07:09:56 +00:00
Maurice 3c823e07f4 fix(tours): search along a tour through the places index along the line, and name what could not be searched (PLN-02, CW-05)
The search along a tour found nothing. It asked the places index for all
kinds of a stretch at once, above the 100 rows and 200 characters of terms
the index takes, so the index refused and every stretch went to the public
Overpass mirrors, which do not answer from this server. Now the index is
asked in small circles strung along the line, one request per kind and
circle within its limits, and an empty circle counts as an answer, so a
city walk lists its sights, museums, parks, cafes, restaurants and markets
in well under a second. The kinds the index does not know (viewpoints,
huts, drinking water, toilets and the like) are asked of Overpass along the
whole line at the same time, one query per stretch of up to 50 km, three at
a time, each with a short deadline of its own; a line past 400 km is
searched along its start and says so. When the index answers no circle at
all, its kinds go to Overpass beside the others instead of after them. A
kind no source could be searched for is named in a calm notice above the
list instead of reporting nothing found, and the reason is written to the
log. Unnamed taps, toilets, shelters, picnic spots, viewpoints, summits,
slipways and repair stands stay in the list and on the map under the name
of their kind; an unnamed garden, cafe or memorial is left out. The places
nearest the line are kept when there are more than sixty, listed from the
start, with the opening hours the source has.

A walk's parks are parks and gardens only, and its museums are museums
and galleries only, no longer the beaches, summits, theatres and cinemas of
the map's explore buttons; a water park the index files under parks is not
one of them. The index also answers markets and summits. The explore
buttons keep their single request, and a search of several kinds that
would exceed the index limits is asked one kind at a time instead of being
refused. The MCP tool search_tour_pois and its wiki row describe the walk
kinds and the new unavailable list, and the OVERPASS_TIMEOUT_MS row says
the search along a tour keeps its own shorter deadline.
2026-10-20 07:09:56 +00:00
Maurice 9ed944c1e6 fix(tours): city walk gates: entity collection, formatting, a walk's name hint and the tests' visits repository
The day assignments entity carries the station visits that hang off it, as
the generator writes it. A city walk gets its own placeholder for an untitled
name, the tour service tests hand in the visits repository the service now
takes, and the files the walk touched are formatted. The theme icons of the
place search overlap by their start margin, so they stack the same way in
both reading directions.
2026-10-20 07:09:56 +00:00
Maurice 2955c61c86 feat(tours): a city walk's arrivals against its stops' opening hours, and a compact place search (CW-04, CW-05)
Opened from a day with a time, each stop shows its estimated arrival: the
start, the walking time shared out along the route, and the stays before
it, at the earliest while a stay is unknown. A stop whose opening hours say
it is closed then, or closes before the stay ends, says so with the times;
hours that cannot be read are shown as they are, and a stop without hours is
unknown, never open. The themes are a two by two grid and the categories one
line with a list to tick, so the search no longer fills the rail. The button
that discards a draft for a new tour says so in two words.
2026-10-20 07:09:56 +00:00
Maurice c97105ce61 feat(tours): fill a city walk from a theme: culture, parks, food or a bit of everything (CW-05)
The search along the route offers a walk's own categories (sights, museums,
parks, views, coffee shops, places to eat, markets) and four themes that set
them at once. A found place says which theme brought it, its source and how
far it lies from the line, straight across; taken in as a stop, it keeps its
name and opening hours. Nothing is taken in without the existing confirm.
2026-10-20 07:09:56 +00:00
Maurice 290961cced feat(tours): check a city walk's stops off for the day it is on (CW-03)
The walk's detail opened from a day lists its stops in order, each with its
stay, importance and protection, and marks one visited or skipped for that
day, or takes it back. The day row shows how many were visited. Marks reach
the other travellers at once and change no route, place or order; a copy or
another day starts with none.
2026-10-20 07:09:56 +00:00
Maurice 4c594876ef feat(tours): plan a city walk with its stops, stays, importance and protection (CW-01, CW-02, CW-10)
City walk is a type of the planner: routed on foot without a hiking limit, on
the standard map. Each stop is edited in its own dialog, its name, its stay
(empty is not known, 0 a point passed through), its importance and whether a
proposed change must leave it; none of it plans the route again. The walk's
time is walking plus the stays, at least that much while a stay is unknown,
with no breaks on top.
2026-10-20 07:09:56 +00:00
Maurice d5d2d6b25e feat(tours): city walks in the contract and on the server (CW-01, CW-02, CW-03, CW-09, CW-10)
A tour can be a city walk. Its points are stations that keep a key across
saves, a name, a stay (0 for a point passed through, empty when unknown), an
importance and a protection; the visits of a walk's stations are kept per day
assignment, so another day or a copy starts with nothing visited. A station
that is gone takes its visits with it. An assistant reads how far a walk got
on a day, what is open and which times are known.
2026-10-20 07:09:56 +00:00
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
Maurice e725d4f0a9 test(packing): sorted A to Z only the category grip stays draggable 2026-10-18 14:43:55 +02:00
Maurice 36f1b5b7d9 refactor(journey): read the stored dismissed suggestions as the list they are written as
The reader also took {id, dismissedAt} records, a shape nothing writes.
2026-10-18 14:07:25 +02:00
Luffy acce063dbf fix: fix journey test fail 2026-10-18 14:04:23 +02:00
Luffy d229613e0e fix(journey): add localized error messages for dismissing suggestions 2026-10-18 14:04:22 +02:00
Luffy 5622113348 fix(journey): persist dismissed suggestions in local storage 2026-10-18 14:04:22 +02:00
Maurice cd2ecf897b test(mcp): search_pois asks the index by Overture path 2026-10-18 13:58:30 +02:00
Maurice f5af9d1259 chore(deps-dev): bump source-map-js to 1.2.2 in /plugin-sdk
Replaces Dependabot's #2680, whose branch sat 983 commits behind dev.
2026-10-18 13:21:54 +02:00
Maurice cecfdf2dcc fix(packing): tell a dragged category by its data type, not by a state flag
A flag set on dragstart could still be unrendered when a quick drag reached
the next card, and the drop was lost. The category now travels in the drag
under its own type, read from the first dragover on and at the drop.
2026-10-18 13:21:54 +02:00
Maurice 23b28a1f3f feat(packing): drag a category by its head to move it among the others
Categories showed in the order they were created, which a list kept per day
cannot undo once the days swap. A grip in each category's head band moves
the whole category onto another's place on desktop. A category has no
position of its own, it shows where its first item is, so the move reorders
the items as one block through the item reorder the list already has: no
new column, and the order reaches everyone the way a dragged item does. The
other view's categories keep their order.
2026-10-18 13:21:54 +02:00
Maurice f7f6b83d33 refactor(journey,docsync): keep both services inside their size baseline
The time placement of a journey entry moves whole into journey-entry-order,
and a moved entry only gets a sort_order when it actually moves, so a patch
never carries an undefined one. The Synology login comment shrinks to a line.
2026-10-18 13:21:53 +02:00
Maurice 097c1da0af fix(mobile): a transport card shows the notes of its booking
On the phone the Transports tab never rendered a booking's notes, so what
someone typed into a flight was only to be found by opening its sheet,
while the booking cards next to it showed theirs. Both cards now take the
notes from one part, sized on the caption tier so the text size setting
reaches them.
2026-10-18 13:21:53 +02:00
Maurice a74a2cf9b5 fix(map): the day route waits out a busy router instead of keeping straight lines
The public OSRM hosts answer 429 above about one request a second. The trip
overview and the road trip waited that out and asked again; the day route
took the first refusal as final and drew straight lines between the stops
until the next edit, which is what a day with a change of mode or a quick
switch between days ran into. Both the day route and the overview now go
through one retry helper: a rate limit is waited out, as long as the host
asks when it says, and a refusal the host meant still ends at once.
2026-10-18 13:21:52 +02:00
Maurice dc1ff06573 fix(docsync): Synology login sends plain credentials, a refused test shows its reason
The login went to auth.cgi with account and password JSON-quoted, the way
the FileStation calls on entry.cgi take their values. entry.cgi reads JSON
where it can; auth.cgi takes a value as it comes, so DSM looked for a user
named "trek" with the quotes and refused every account. Both go plain now.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests cover the reader with extracts written in the test, the tag rules, the extraction and lock grouping, the geo helpers, the Geofabrik client, the region queue, the read side, both controllers, the job, the repositories and the HTTP routes; the domain gets its coverage entry.
2026-10-16 22:27:29 +00:00
Maurice 2c3b20bb57 feat(tours): paddle portages and locks, paddle time, wind at the start and obstacle data from OpenStreetMap
PAD-03 portages and locks on planned tours and recorded tracks, PAD-07 paddle time with portage and lock stops, PAD-08 wind forecast for the start, PAD-05A regional obstacle import from Geofabrik extracts with admin endpoints and a monthly refresh, PAD-05B first cut of the obstacle layer on Leaflet, GL and 3D. Work in progress: checklist (PAD-06), check bench (PAD-09), admin UI, translations and tests follow.
2026-10-16 23:44:58 +02:00
Maurice d705bd9dd0 test(tours): cover the seamark overlay on GL maps and the tour type fields 2026-10-16 23:14:52 +02:00
Maurice 704621d0c2 refactor(tours): move the detail view's measures into their own component 2026-10-16 23:14:52 +02:00
Maurice 9494521ac7 feat(tours): plan paddle tours, hand-drawn legs, reverse and return to start, maps per tour type
- Paddle as a plannable tour type: one boat (kayak, canoe, SUP, other), river or
  lake, a pace that a boat change never overwrites; time is distance over pace
- Hand-drawn legs beside routed ones, drawn dotted and labelled as not routed,
  kept through save, reopen and edits elsewhere
- Reverse a tour and close it back to its start, each one undo step
- GPX import shows a preview first (line, measures, duplicates, type) and only
  the confirmation stores the tour
- Base maps and overlays per tour type in the planner: hike keeps topo and the
  via ferrata layer, paddle gets the OpenSeaMap seamark overlay
2026-10-16 23:14:52 +02:00
Maurice 33b006014b fix(settings): typing in the wanderer address and key fields no longer gets undone
useToast handed back a new object on every render. useWandererConnection lists
toast in its load effect, so each keystroke reloaded the saved settings and
reset both fields to them.
2026-10-16 11:33:19 +02:00
Maurice 52ed63715c Tours: tests follow the provenance read and the shared day assignment, the tours addon ends MCP sessions, a line without a point is not copied
The repository stubs of the tour service tests answer findProvenance, which getTour and createTour read since P2C-02.
Switching the Tours addon off now ends live MCP sessions like every other addon that gates a tool (MCP-GATE-PARITY-001).
copyTour refuses a line without a point instead of placing the copy at 0/0, and new tests cover the reachable paths
of the copy, the source check and the provenance of a snapshot without its import record, including a trip copy
keeping that record. On the client the shared day assignment hands the plan exactly the tour and the day, the new
visibility store is reset between tests, and the tests of the sidebar, the detail, the phone sheet and the origin
section match the views this PR built.
2026-10-15 22:34:33 +00:00
Maurice 1d7758b819 Tours: the trip copy keeps its copied tours in one map, so trips.service stays at its size baseline; unused TourMapVisibility type removed 2026-10-15 21:40:19 +00:00
Maurice b33727ec75 Tours: no non-null assertion, useless escape or awaited loop in the tour and trip copy paths 2026-10-15 23:25:16 +02:00
Maurice 97b5f36ef0 Tours: the list filter names a tour planned here TREK, as its badge does 2026-10-15 23:11:39 +02:00
Maurice 845979902c Tours: client tests for the new hooks and views, row chips fade at their end, admin user table aligned; strict and lint ratchets green 2026-10-15 23:08:46 +02:00
Maurice a0884685ef Map: tracks drawn from their own modules on both maps; mobile filter chips on the type scale 2026-10-15 22:25:34 +02:00
Maurice 485e360c64 Tours: save a tour as a new, independent tour (P2C-07) 2026-10-15 22:18:41 +02:00
Maurice 356df90095 Tours: read the saved tours of a trip over MCP (P2D-MCP-01) 2026-10-15 22:08:39 +02:00
Maurice cd1b1410bd Tours: filter a tour list by source, routing limit and type where it varies (PR1-13) 2026-10-15 22:02:41 +02:00
Maurice 3d9095ccde Tours: provenance contract, source check, one assignment path and the map overview
- P2C-02: tours record their origin (planned, GPX with file name, wanderer)
  instead of reading it off their shape; the detail answers a provenance
  contract and a trip copy keeps a tour's import record
- MF-03: a wanderer snapshot's source can be checked on purpose, read only,
  with a confirmed reimport as a new snapshot
- PR1-18: every way of putting a tour on a day shares one set of rules
- PR1-07/08: each person can hide tours from the trip map; the list shows
  each tour's line colour and the picked tour stands out on both maps
2026-10-15 21:51:22 +02:00
Maurice 018d2d1425 Tours/wanderer: cover the new service, repository and restore paths
- unit suites for WandererService (every refusal and fallback), the
  wanderer connection and tour source repositories, and restoring a
  recorded track (GPX and wanderer imports, empty or missing track, 404s)
- client cases for a body that breaks off and the trail count fallbacks
- an import whose realtime notification fails still stands (e2e)
- the search controller passes the validated body on; the schema already
  defaults q and page, so the fallbacks there could never run
2026-10-15 20:15:17 +02:00
Maurice cab742e641 Tours: bring the pinned counts and test doubles up to the new tables and switch
- tours locale key count, entity count and NOT NULL field count follow
  the wanderer tables and strings
- the shared MSW admin handlers answer /api/admin/tours-wanderer
- the mobile tour detail test checks the dialog, not the dropped heading
- the refused-origin e2e sends one request per case instead of four at once
2026-10-15 19:25:22 +02:00
Maurice 11f23997e8 Tours: copy a planned tour's import link in the repository, and teach the tests the new columns 2026-10-15 18:38:44 +02:00
Maurice f1c437b8b8 Keep api/client.ts and trips.service.ts within their size baselines
The admin switch for wanderer moves to api/wanderer.ts and the recorded
track restore to api/recordedTrack.ts; trips.service.ts keeps dev's
formatting with only the derived-from remap on copy.
2026-10-15 18:16:25 +02:00
Maurice f690065de8 Tours: resolve the distance unit once where tour facts are built 2026-10-15 17:51:19 +02:00
Maurice 782e613506 Tours: day GPX import, wanderer trails and planning over an import
- GPX files drop onto the map or a day; the import lands on that day
- wanderer connection per user in Integrations; the workspace offers the
  user's own trails only, behind an admin switch under the Tours addon
- wanderer difficulty is stored on the SAC scale (T1 to T3), backfilled
- a GPX or wanderer import can be planned as an editable tour, either in
  place (the recorded track is kept and can be restored) or as a new tour
  that remembers the import it came from
- rails, rows and detail dialog follow the plan sidebar's cards and badges
- admin addon shelves with more than one row fold, collapsed by default
- reverse-geocoded waypoint names are cached on the device
2026-10-15 17:44:15 +02:00
Maurice 31ffcf1228 fix(ferratas): sort route tile keys with localeCompare 2026-10-15 12:09:48 +02:00
Maurice 309bef4db7 fix(ferratas): leaving 3D with via ferratas on no longer crashes, the removed map is not asked for its layers 2026-10-14 23:37:58 +02:00
Maurice 6e59240328 test(tours): follow the reworked planner, ferrata and map stage in their suites; keep the map's waypoint list stable between renders 2026-10-14 23:01:52 +02:00
Maurice bdb082d490 test(ferratas): a way without geometry really has none, the tile cache's columns join the NOT NULL count 2026-10-14 22:15:45 +02:00
Maurice 8957503dbe test(i18n): the tours locale count follows the ferrata and planner strings 2026-10-14 21:41:21 +02:00
Maurice 0a50119c52 chore(layout): lower the navbar's theme baseline to what it holds now 2026-10-14 21:24:15 +02:00
Maurice 1d090e0ac9 fix(tours): the 3D relief slider uses the app's range style 2026-10-14 21:14:28 +02:00
Maurice d118c3cee9 chore(tours): drop the planner strings nothing shows any more 2026-10-14 21:09:05 +02:00
Maurice e3f3b0458b style(tours): format the touched planner and map files 2026-10-14 20:54:44 +02:00
Maurice 976a612351 fix(tours): ferrata map tokens in a style module of their own, keep module-private helpers unexported 2026-10-14 20:44:53 +02:00
Maurice d87e276831 fix(tours): translate the ferrata badge everywhere, count the tile cache writes as standalone, settle the new test warnings 2026-10-14 20:34:20 +02:00
Maurice 62b50e3f9b fix(tours): a ferrata waypoint's number keeps the size of the others, its ring sits inside 2026-10-14 20:21:39 +02:00
Maurice 84d637f5fe feat(tours): via ferratas on the tour as fixed legs of their own line, crossing warnings, violet ferrata look, tour list and editor polish 2026-10-14 20:21:39 +02:00
Maurice 77c4785a9d fix(maps): the hover label on the GL maps goes away again when the pointer leaves a pin or the map moves 2026-10-14 20:21:39 +02:00
Maurice 6d43f3a4e6 feat(tours): hiking-rule walking time, source marks in the map search, tour rows and status badges reworked, ferrata card in the map glass 2026-10-14 20:21:39 +02:00
Maurice a805b4b882 fix(maps): hover labels on the GL maps use the app font, not the map's Arial 2026-10-14 20:21:39 +02:00
Maurice e3983ef857 feat(tours): tidy the tour editor and tour list, set points only on purpose, name waypoints on the map 2026-10-14 20:21:39 +02:00
Maurice 160bc89e78 feat(ferratas): keep loaded via ferratas in a tile cache on server and device, group segments, own look and count badge 2026-10-14 20:21:38 +02:00
Maurice a9882a6415 feat(tours): via ferrata layer mounted on the tours map (2D and 3D) 2026-10-14 20:21:37 +02:00
Maurice 6b7461ee03 feat(planner): a floating variant of the location search in the map controls' frosted glass 2026-10-14 20:16:15 +02:00
Maurice 0eca20f2a4 feat(layout): in a trip the back arrow and the trip name share one pill beside the logo 2026-10-14 20:16:14 +02:00
Maurice 796bbb065d feat(layout): the trip name sits beside the logo as a badge instead of after a slash 2026-10-14 20:15:57 +02:00
Maurice 20d8bae46b test(tours): the wiring test follows the map stage's shared edit gate 2026-10-14 14:02:27 +02:00
Maurice 98cc57942b feat(tours): 3D terrain view with flyover and grade colours, imported tours carry no hiking difficulty
The tours map gets a 3D mode on the same map surface between the side panels:
a "3D" switch in the layer pill drapes the route over open Terrarium elevation
tiles (AWS Open Data), colours it by grade, plays a flyover tied to the
elevation profile in the rail, and keeps planning working by clicking and
dragging waypoints on the terrain. The corridor around the route is preloaded
before the flyover so the landscape never loads in front of the camera.

GPX imports no longer get a made-up hiking difficulty: max_hiking_difficulty
becomes nullable (new migration, waypoints kept), the import writes null and
the client shows no T badge for it. Drawn tours still default to T2.

Info hints in the planner show their tooltip only, and the discard
confirmation reads "Discard changes".
2026-10-14 14:02:27 +02:00
Maurice e7f2e17954 fix(ai-import): give the keeper search a starting flight 2026-10-14 13:24:27 +02:00
Maurice f746c22a83 test: keep the day plan sidebar test within its size baseline 2026-10-14 13:24:27 +02:00
Maurice 723bfa507b test: follow the note row's action wrapper and keep waitFor polling in the users refresh test 2026-10-14 13:24:27 +02:00
Maurice 3c8fa07f44 fix(admin): show when users were last active and keep the list current
The column showed the last sign-in, which a renewing cookie leaves weeks
behind for someone using TREK daily. It now shows the later of the sign-in
and the last request. Saving an edit no longer blanks the row's avatar,
activity and online dot, and the list refreshes every 30 seconds while open.
2026-10-14 13:24:27 +02:00
Maurice 4ceade9d8b fix(ai-import): read a long document's end and charge a shared total once (#2652, #2651)
Cloud models got the first 4,000 characters, so totals and lodging sections
further down never reached them, without a word. They now get 24,000 with the
start and the end kept and a warning when the middle is left out; a server
whose context is too small is asked once more with 8,000. A total the model
repeated on every flight of one booking stays on the first flight only, when
the document prints it once as a total, and the prompt says so too.
2026-10-14 13:24:27 +02:00
Maurice dae20c166a fix(admin): keep an Anthropic Messages gateway as the AI parsing base URL (#2655)
Both admin forms hid the field for Anthropic and the shared hook saved an
empty address, wiping a gateway configured on the server. The field shows for
every provider now and is saved as typed; switching provider empties it, so an
address meant for one never receives another provider's key.
2026-10-14 13:24:27 +02:00
Maurice 0d4ddb2089 fix(booking-import): join flights only on a layover that can be shown (#2653)
A missing arrival time skipped the time check, so a return listed first
merged into the outbound flight. The layover is now measured from the
arrival, or from the departure when there is none, must be forward and under
a day, and a leg landing anywhere the chain has been is a return.
2026-10-14 13:24:27 +02:00
Maurice 775b35d5af fix(dashboard): leave planned public transit out of upcoming reservations
Journeys from the transit search are routing, not bookings, and a day of them
pushed the real bookings out of the six slots. Booked trains and buses keep
their own types and stay. The MCP tool reads the same feed.
2026-10-14 13:24:27 +02:00
Maurice 76b7b305b2 fix(journey): do not count the suggested weather as an unsaved change
The entry dialog fills an empty weather from the forecast, and leaving it
untouched then asked to discard changes. The suggestion is not an edit. In
development StrictMode also dropped the suggestion before it arrived.
2026-10-14 13:24:27 +02:00
Maurice eb4e74b5c3 fix(forms): keep password managers out of a place's contact fields and the admin user forms
1Password filled the signed-in user's login into a place's e-mail field, and
offered the admin's own credentials for an account being created. The fields
now carry each manager's opt-out; autocomplete=off alone is ignored.
2026-10-14 13:24:27 +02:00
Maurice 10a987fec1 fix(files): list a hotel booking's files and files pinned to a stop on the place card
A hotel booking reaches its place through the stay, not its own place_id, so
the card listed the booking but not its confirmation. Links to a stop, which
MCP's link_trip_file and plugins can make, were never returned by the file
list at all; it now carries linked_assignment_ids. Without files the row no
longer opens to nothing.
2026-10-14 13:24:27 +02:00
Maurice 7c6b7622fc fix(planner): open the places filters across the whole sidebar row
Hung off an icon near the end of the row, the category and rating lists ran
out of the sidebar and were cut off.
2026-10-14 13:24:27 +02:00
Maurice 341520f98b fix(planner): give day plan rows their full width until the controls are wanted
The hover controls took their room at rest, so a place's line and a note's
text broke early. A place now gives way only while hovered, a note's controls
fade in over the end of its text in the note's own colour, and a picked place
shows none. All three controls of a place come out together: the add-booking
ticket waited for a React hover state and came a beat late. A click no longer
leaves them showing; keyboard focus and an open menu do.
2026-10-14 13:24:27 +02:00
Maurice 2fe8590c0b fix(planner): show a place photo again once its missing cache file is fetched back
A proxy image_url whose cached file was gone answered 204, and the self-heal
got the same URL back, so the list avatar kept its broken image until a
reload while the inspector showed the photo. The image is now mounted afresh
after the refetch; a second failure still ends on the category icon.
2026-10-14 13:24:27 +02:00
Maurice b3ef13616f fix(nav): put the prerelease badge beside the logo and never let the actions cover the tab pill
Both side columns keep their content width, so the pill gives way: it drops
its labels to icons when they no longer fit (measured, since plugins and
languages change the width) and scrolls only after that. The username is no
longer cut at 96px.
2026-10-14 13:24:27 +02:00
Maurice db531d2bef test(tours): the wiring test reads the map stage, the phone timeline test hands the planner its tours 2026-10-14 12:05:49 +02:00
Maurice f463c44d03 test(i18n): the tours locale count follows the three removed button keys 2026-10-14 11:22:16 +02:00
Maurice 07a82be64e fix(tours): short save and discard labels, info hints level with their label 2026-10-14 11:11:48 +02:00
Maurice 4efc50c012 chore(tours): keep the waypoint drag coordinate type module-private 2026-10-14 10:48:53 +02:00
Maurice 7a2846b3fa style(tours): format the tour service test 2026-10-14 10:41:21 +02:00
Maurice e87c68b811 test(tours): seed and read the tour tests through the row factories instead of raw SQL 2026-10-14 10:33:14 +02:00
Maurice 26833f374c fix(tours): keep the places repository at its size and format baselines 2026-10-14 10:23:22 +02:00
Maurice 7795061630 refactor(tours): move the planner additions out of the map, sidebar and page files that grew past their size baseline 2026-10-14 10:00:08 +02:00
Maurice 09227ec0d1 fix(tours): the map search sits between the panels without a physical side, and the day row facts use the caption tier 2026-10-14 09:22:38 +02:00
Maurice dbee1664af fix(tours): hold the client warning ratchet and the server format baseline flat after the rebase onto dev 2026-10-14 09:11:47 +02:00
Maurice 4c487ceefa fix(tours): keep the generated entity byte-identical and the strict baseline flat after the rebase onto dev 2026-10-14 08:49:47 +02:00
elmocito dd43999f1f fix(tours): resolve client lint warnings 2026-10-14 08:39:32 +02:00
elmocito 9a84890142 fix(tours): align generated Tour entity 2026-10-14 08:39:32 +02:00
elmocito eb13d9f785 fix(i18n): add Tours locale keys 2026-10-14 08:39:31 +02:00
elmocito 57ea219a43 feat(tours): complete planner follow-up scope
This commit completes the reviewed Tours follow-up scope after the Tours MVP was merged into dev.

Included feature blocks:

- RC-01: identify Tours in PLAN day rows and show compact Tour facts;
- RC-06: search for a city or address in TOUR-PLANNER without hidden writes;
- RC-07: reorder Tour waypoints in the left sidebar;
- RC-08: drag Tour waypoints on Leaflet, MapLibre and Mapbox with stable viewport, history and routing behavior;
- RS-01: persist Tour descriptions and HTTPS information links with local provider classification;
- RS-02: persist breaks/additional time and calculated or manually adjusted planned Tour duration without changing scheduling semantics.
The implementation preserves:

- ordinary Place and Legacy Track behavior;
- Tours-off fallback behavior;
- existing GPX/import behavior;
- Tour assignment and deletion behavior;
- calculated routing duration as a separate value;
- existing Tour provenance fields;
- keyboard/list alternatives for waypoint editing.
Not included:

- PR1-09 trip-wide Tour summary;
- Wanderer integration;
- GPX import changes;
- new Tour types;
- Tour-to-Day drag-and-drop;
- further roadmap or Post-Release features.
Validation:

- focused RC-01/RC-06/RC-07/RC-08/RS-01/RS-02 tests passed;
- affected shared/server/client typechecks passed;
- migration and generated DB type checks passed;
- scoped ESLint passed;
- staged diff checks passed;
- owner manual tests passed for all six feature blocks.
2026-10-14 08:39:31 +02:00
Maurice af9f013c5e docs(chart): NOTES lists every key the chart reads from existingSecret
SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY and DEMO_ADMIN_PASS were read but missing from the install notes; values.yaml already listed them.
2026-10-13 21:10:41 +02:00
Maurice f870dfc6d1 fix(server): the newer-database refusal also names a development build as the cause
A database a feature-branch build migrated carries migrations this build does not ship too, and was told it came from a newer TREK.
2026-10-13 21:10:41 +02:00
Maurice bf26175724 docs(server): say which builds refuse a migrated database and which do not
The pre-migrate snapshot docstring said an older release refuses a database a newer TREK migrated. Only images from 5.0 on do; 4.3.x and earlier start on it without a warning. server/CLAUDE.md said anything the legacy baseline cannot map refuses the boot, which holds only above step 258.
2026-10-13 21:10:41 +02:00
2027 changed files with 208818 additions and 4725 deletions
+339 -144
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
@@ -239,8 +261,11 @@ jobs:
# constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at
# runtime, if anything reads it at all. This is the step that
# catches it.
# catches it. src and tests together are past 570,000 lines and need
# about 4.3 GB, over Node's default heap.
run: cd server && npm run typecheck:tests
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Typecheck entity generator
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
@@ -257,6 +282,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 +319,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 +425,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 +446,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 +461,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 +524,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 +541,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 +597,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 +655,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 +681,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 +746,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 +802,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 +828,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 +893,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 +919,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 +978,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 +1005,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 +1031,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 +1045,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 +1055,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 +1127,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 +1204,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 +1275,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.
+3 -2
View File
@@ -5,8 +5,9 @@
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The chart then
renders no Secret of its own and reads every secret env var from that secret: `ENCRYPTION_KEY`
(renamable via `existingSecretKey`), plus `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `OIDC_CLIENT_SECRET`,
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET` and
`VAPID_PRIVATE_KEY`. Missing keys are simply left unset, so only provide the ones you use.
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET`,
`VAPID_PRIVATE_KEY`, `SMTP_PASS`, `MAPBOX_ACCESS_TOKEN`, `CARTO_API_KEY` and
`DEMO_ADMIN_PASS`. Missing keys are simply left unset, so only provide the ones you use.
- If left empty, the server resolves the key automatically: existing installs fall back to
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
auto-generate a key persisted to the data PVC.
+8 -8
View File
@@ -1,25 +1,25 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 876,
"@typescript-eslint/no-misused-promises": 874,
"no-console": 88,
"complexity": 231,
"@typescript-eslint/no-explicit-any": 148,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 147,
"trek/no-swallowed-catch": 133,
"max-params": 35,
"trek/store-without-selector": 69,
"react-hooks/refs": 561,
"react-hooks/refs": 558,
"react-hooks/set-state-in-effect": 197,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121,
"@typescript-eslint/no-unused-vars": 155,
"@typescript-eslint/no-unused-vars": 129,
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 15,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 42,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 46,
"unused-disable-directive": 6,
"max-depth": 21,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1949,
"@typescript-eslint/no-non-null-assertion": 1946,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+12 -1
View File
@@ -571,6 +571,12 @@
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
},
{
"feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk",
"hooks": ["src/components/Planner/useTransitLegPlanner.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
},
{
"feature": "Reservation form incl. hotel accommodation creation",
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
@@ -717,12 +723,17 @@
"hooks": ["src/components/Tours/tourPresentation.ts"],
"desktop": [
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/ToursSidebar.tsx",
"src/components/Tours/planner/TourPlannerPanels.tsx",
"src/components/Tours/tourParts.tsx"
],
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
},
{
"feature": "Tours list facets",
"hooks": ["src/components/Tours/useTourListFilters.ts"],
"desktop": ["src/components/Tours/ToursSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/places/MPlacesBrowser.tsx"]
},
{
"feature": "Transport booking detail view: reservationFiles",
"hooks": ["src/utils/reservationFiles.ts"],
-1
View File
@@ -1028,7 +1028,6 @@
"src/components/Todo/useTodoList.ts",
"src/components/Tours/PlacesToursModeSwitch.tsx",
"src/components/Tours/TourDetailDialog.test.tsx",
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/TourListRow.tsx",
"src/components/Tours/ToursSidebar.test.tsx",
"src/components/Tours/ToursSidebar.tsx",
-5
View File
@@ -139,11 +139,6 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en',
}))
),
{
file: 'components/Tours/planner/TourPlannerPanels.tsx',
template: 'tours.planner.${role}',
because: "roleLabel takes role: 'start' | 'via' | 'end', and tours.planner.start/via/end exist",
},
];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+1 -1
View File
@@ -8,7 +8,7 @@
"collection.ts": 35,
"common.ts": 7,
"dashboard.ts": 65,
"dawarich.ts": 16,
"dawarich.ts": 15,
"dayplan.ts": 18,
"docsync.ts": 25,
"files.ts": 4,
+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;
}
-1
View File
@@ -130,7 +130,6 @@
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
"src/pages/JourneyDetailPage.tsx",
"src/pages/OAuthAuthorizePage.tsx",
"src/pages/TripPlannerPage.tsx",
"src/pages/admin/AdminSettingsTab.tsx",
"src/pages/admin/useAdmin.ts",
"src/pages/admin/useNotificationDefaults.ts",
+6 -7
View File
@@ -1,18 +1,18 @@
{
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1916,
"src/api/client.ts": 1910,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1461,
"src/components/Map/MapView.tsx": 1401,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2425,
"src/components/Map/MapViewGL.tsx": 2293,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5380,
"src/components/Planner/DayPlanSidebar.tsx": 2978,
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
"src/components/Planner/DayPlanSidebar.tsx": 2969,
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
"src/components/Planner/PlaceFormModal.tsx": 1250,
"src/components/Planner/PlaceInspector.tsx": 1304,
@@ -24,8 +24,7 @@
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1168,
"src/pages/TripPlannerPage.wiring.test.tsx": 2041,
"src/pages/TripPlannerPage.tsx": 1141,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
-1
View File
@@ -89,7 +89,6 @@
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
"src/components/Tours/tourParts.tsx": 11,
"src/components/Trips/TripFormModal.tsx": 3,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/Vacay/VacayCalendar.tsx": 1,
+3 -3
View File
@@ -55,14 +55,14 @@
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 23,
"components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3,
"components/Map/LocationButton.tsx": 1,
"components/Map/MapPluginMarkers.tsx": 2,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 10,
"components/Map/MapView.tsx": 8,
"components/Map/MapViewGL.tsx": 11,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
@@ -269,7 +269,7 @@
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
+1 -6
View File
@@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Day, Place, Trip } from '../types'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
import { randomId } from '../utils/randomId'
import { postProviderPhotosInBatches } from './providerPhotoBatches'
import {
@@ -525,11 +525,6 @@ export const toursApi = {
apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data),
update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data),
importGpx: (tripId: number | string, file: File): Promise<TourImportGpxResponse> => {
const fd = new FormData()
fd.append('file', file)
return postMultipart(`/trips/${tripId}/tours/import/gpx`, fd)
},
}
export const assignmentsApi = {
+54
View File
@@ -0,0 +1,54 @@
// FE-API-COLLECTION-TOURS-001 to FE-API-COLLECTION-TOURS-002
//
// LIB-03: every method pins its verb, its path and its body, and hands back what
// the server answered.
import { http, HttpResponse, type JsonBodyType } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { collectionToursApi } from './collectionTours';
const BASE = '/api/addons/collections/3/tours';
let calls: Array<{ method: string; path: string; body: unknown }> = [];
beforeEach(() => {
calls = [];
});
function record(data: JsonBodyType | null) {
return async ({ request }: { request: Request }) => {
const text = await request.text();
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
return data === null ? new HttpResponse(null, { status: 200 }) : HttpResponse.json(data);
};
}
describe('collectionToursApi', () => {
it('FE-API-COLLECTION-TOURS-001: lists, reads, keeps and removes the tours of a collection', async () => {
server.use(
http.get(BASE, record({ tours: [] })),
http.get(`${BASE}/4`, record({ tour: { id: 4 }, line: [], points: [], trips: [] })),
http.post(BASE, record({ tour: { id: 5 } })),
http.delete(`${BASE}/4`, record({ success: true }))
);
expect(await collectionToursApi.list(3)).toEqual({ tours: [] });
expect((await collectionToursApi.detail(3, 4)).tour).toEqual({ id: 4 });
expect(await collectionToursApi.save(3, { source_trip_id: 2, source_place_id: 7, name: 'Kept' })).toEqual({
tour: { id: 5 },
});
expect(await collectionToursApi.remove(3, 4)).toBeUndefined();
expect(calls).toEqual([
{ method: 'GET', path: BASE, body: null },
{ method: 'GET', path: `${BASE}/4`, body: null },
{ method: 'POST', path: BASE, body: { source_trip_id: 2, source_place_id: 7, name: 'Kept' } },
{ method: 'DELETE', path: `${BASE}/4`, body: null },
]);
});
it('FE-API-COLLECTION-TOURS-002: takes a kept tour into a trip with the choice about its points', async () => {
server.use(http.post('/api/trips/6/tours/from-collection', record({ tour: { place_id: 9 }, waypoints: [] })));
const body = { collection_id: 3, collection_tour_id: 4, include_waypoints: false };
expect((await collectionToursApi.takeOver(6, body)).tour).toEqual({ place_id: 9 });
expect(calls).toEqual([{ method: 'POST', path: '/api/trips/6/tours/from-collection', body }]);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type {
CollectionTourDetailResponse,
CollectionTourListResponse,
CollectionTourSaveRequest,
CollectionTourSaveResponse,
CollectionTourTakeOverRequest,
TourCreateResponse,
} from '@trek/shared';
import apiClient from './client';
const base = (collectionId: number) => `/addons/collections/${collectionId}/tours`;
/** LIB-03: the whole tours a collection keeps, keeping one, removing one and taking one into a trip. */
export const collectionToursApi = {
list: (collectionId: number, signal?: AbortSignal): Promise<CollectionTourListResponse> =>
apiClient.get(base(collectionId), { signal }).then((r) => r.data),
detail: (collectionId: number, tourId: number, signal?: AbortSignal): Promise<CollectionTourDetailResponse> =>
apiClient.get(`${base(collectionId)}/${tourId}`, { signal }).then((r) => r.data),
save: (collectionId: number, body: CollectionTourSaveRequest): Promise<CollectionTourSaveResponse> =>
apiClient.post(base(collectionId), body).then((r) => r.data),
remove: (collectionId: number, tourId: number): Promise<void> =>
apiClient.delete(`${base(collectionId)}/${tourId}`).then(() => undefined),
takeOver: (tripId: number, body: CollectionTourTakeOverRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/from-collection`, body).then((r) => r.data),
};
+33
View File
@@ -0,0 +1,33 @@
// FE-API-FERRATAS-001 to FE-API-FERRATAS-002
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { ferratasApi } from './ferratas';
const reply = { ferratas: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
describe('ferratasApi.list', () => {
it('FE-API-FERRATAS-001: asks GET /api/trips/:tripId/ferratas with the bbox and returns the body', async () => {
let url = '';
server.use(
http.get('/api/trips/7/ferratas', ({ request }) => {
url = request.url;
return HttpResponse.json(reply);
})
);
await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).resolves.toEqual(reply);
expect(new URL(url).searchParams.get('bbox')).toBe('46.500,11.700,46.600,11.900');
});
it('FE-API-FERRATAS-002: an unreachable Overpass (502) rejects', async () => {
server.use(
http.get('/api/trips/7/ferratas', () =>
HttpResponse.json({ error: 'Overpass unavailable', code: 'ferratas_unavailable' }, { status: 502 })
)
);
await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).rejects.toBeTruthy();
});
});
+25
View File
@@ -0,0 +1,25 @@
import apiClient from './client';
/**
* How long the browser waits for GET /api/trips/:tripId/ferratas.
*
* The server races the configured Overpass mirrors under one OVERPASS_TIMEOUT_MS
* (25 s by default) and answers 502 when none of them made it. The shared 8 s
* on `apiClient` would give up on a slow but healthy mirror and show an error
* the server was about to resolve, so this sits just above the server's own
* ceiling.
*/
const FERRATAS_TIMEOUT_MS = 30_000;
/**
* GET /api/trips/:tripId/ferratas: the via ferratas OpenStreetMap knows inside
* one map area. In its own file rather than in the `client.ts` god module. The
* answer comes back unparsed; repo/ferrataRepo.ts checks it against the shared
* contract before anything draws it.
*/
export const ferratasApi = {
list: (tripId: number, bbox: string, signal?: AbortSignal): Promise<unknown> =>
apiClient
.get(`/trips/${tripId}/ferratas`, { params: { bbox }, signal, timeout: FERRATAS_TIMEOUT_MS })
.then((response) => response.data as unknown),
};
+34
View File
@@ -0,0 +1,34 @@
// FE-API-HORSE-001 to FE-API-HORSE-002
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { horseRoutesApi } from './horseRoutes';
const reply = { routes: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
const BBOX = '47.10000,11.20000,47.30000,11.50000';
describe('horseRoutesApi.list', () => {
it('FE-API-HORSE-001: asks GET /api/trips/:tripId/tours/horse-routes with the view and returns the body', async () => {
let url = '';
server.use(
http.get('/api/trips/7/tours/horse-routes', ({ request }) => {
url = request.url;
return HttpResponse.json(reply);
})
);
await expect(horseRoutesApi.list(7, BBOX)).resolves.toEqual(reply);
expect(new URL(url).searchParams.get('bbox')).toBe(BBOX);
});
it('FE-API-HORSE-002: no Overpass mirror answering (502) rejects', async () => {
server.use(
http.get('/api/trips/7/tours/horse-routes', () =>
HttpResponse.json({ error: 'Horse route data is unavailable right now' }, { status: 502 })
)
);
await expect(horseRoutesApi.list(7, BBOX)).rejects.toBeTruthy();
});
});
+21
View File
@@ -0,0 +1,21 @@
import apiClient from './client';
/**
* How long the browser waits for GET /api/trips/:tripId/tours/horse-routes.
* The server races the Overpass mirrors under its own deadline (25 s by
* default) and answers 502 when none made it, so this sits just above it, as
* the via ferrata layer's wait does.
*/
const HORSE_ROUTES_TIMEOUT_MS = 30_000;
/**
* HOR-01 GET /api/trips/:tripId/tours/horse-routes: the signposted horse
* routes OpenStreetMap knows inside one map view. The answer comes back
* unparsed; repo/horseRouteRepo.ts checks it against the shared contract.
*/
export const horseRoutesApi = {
list: (tripId: number | string, bbox: string, signal?: AbortSignal): Promise<unknown> =>
apiClient
.get(`/trips/${tripId}/tours/horse-routes`, { params: { bbox }, signal, timeout: HORSE_ROUTES_TIMEOUT_MS })
.then((response) => response.data as unknown),
};
+16
View File
@@ -0,0 +1,16 @@
import { paddleObstaclesResponseSchema, type PaddleObstaclesResponse } from '@trek/shared';
import apiClient from './client';
/** PAD-05A: the paddle objects of a map area and how far TREK's data covers it. */
export const paddleObstaclesApi = {
inBox: (
box: { south: number; west: number; north: number; east: number },
signal?: AbortSignal
): Promise<PaddleObstaclesResponse> =>
apiClient
.get('/paddle/obstacles', {
params: { bbox: [box.south, box.west, box.north, box.east].map((v) => v.toFixed(5)).join(',') },
signal,
})
.then((r) => paddleObstaclesResponseSchema.parse(r.data)),
};
+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),
};
+41
View File
@@ -0,0 +1,41 @@
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, and those paddlers asked for (PAD-11C). */
export const paddleRegionsApi = {
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 })
.then((r) => paddleRegionCatalogResponseSchema.parse(r.data).regions),
add: (id: string) =>
apiClient
.post('/admin/paddle-regions', { id } satisfies PaddleRegionRequest, GEOFABRIK)
.then((r) => paddleRegionSchema.parse(r.data)),
refresh: (id: string) =>
apiClient
.post('/admin/paddle-regions/refresh', { id } satisfies PaddleRegionRequest)
.then((r) => paddleRegionSchema.parse(r.data)),
remove: (id: string) =>
apiClient
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)),
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)),
};
+8
View File
@@ -0,0 +1,8 @@
import type { TourDetailResponse } from '@trek/shared';
import apiClient from './client';
/** An import that was planned over in place keeps its recorded track; this brings it back. */
export const recordedTrackApi = {
restore: (tripId: number | string, placeId: number | string): Promise<TourDetailResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/restore-recorded`).then((r) => r.data),
};
+14
View File
@@ -0,0 +1,14 @@
import type { TourActivityAssignRequest, TourDetailResponse } from '@trek/shared';
import apiClient from './client';
/** ACT-01: the recorded tracks of a tour, assigned and taken off; both answer the tour's detail. */
export const tourActivitiesApi = {
assign: (
tripId: number | string,
placeId: number | string,
body: TourActivityAssignRequest
): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}/activities`, body).then((r) => r.data),
unassign: (tripId: number | string, placeId: number | string, activityId: number): Promise<TourDetailResponse> =>
apiClient.delete(`/trips/${tripId}/tours/${placeId}/activities/${activityId}`).then((r) => r.data),
};
+10
View File
@@ -0,0 +1,10 @@
import type { TourActivityExclusionRequest, TourActivityStatsResponse } from '@trek/shared';
import apiClient from './client';
/** STA-02: the user's own tour activities across their trips, and leaving one out of them; both answer the sums. */
export const tourActivityStatsApi = {
get: (signal?: AbortSignal): Promise<TourActivityStatsResponse> =>
apiClient.get('/tours/activity-stats', { signal }).then((r) => r.data),
setExcluded: (body: TourActivityExclusionRequest): Promise<TourActivityStatsResponse> =>
apiClient.put('/tours/activity-stats/exclusions', body).then((r) => 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');
+8
View File
@@ -0,0 +1,8 @@
import type { TourCopyRequest, TourCreateResponse } from '@trek/shared';
import apiClient from './client';
/** P2C-07: a tour of the trip saved again as a new, independent tour. */
export const tourCopiesApi = {
copy: (tripId: number | string, placeId: number | string, body: TourCopyRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/copy`, body).then((r) => r.data),
};
+22
View File
@@ -0,0 +1,22 @@
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
import { postMultipart } from './client';
/** A GPX, KML or KMZ file and the fields beside it, as the Tours import routes read a multipart body. */
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
const fd = new FormData();
fd.append('file', file);
for (const [key, value] of Object.entries(fields ?? {})) if (value !== undefined) fd.append(key, value);
return fd;
}
/**
* The Tours file import: a GPX, KML or KMZ file (IO-02) read for its preview
* first (PAD-02), then imported as the type chosen.
*/
export const tourGpxApi = {
/** What the file holds, read by the import's own parser; nothing is stored. */
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
postMultipart(`/trips/${tripId}/tours/import/gpx/preview`, gpxForm(file)),
import: (tripId: number | string, file: File, fields?: TourGpxImportFields): Promise<TourImportGpxResponse> =>
postMultipart(`/trips/${tripId}/tours/import/gpx`, gpxForm(file, fields)),
};
+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),
};
+14
View File
@@ -0,0 +1,14 @@
import { tourDetailResponseSchema, type TourDetailResponse, type TourObstacleReviewsRequest } from '@trek/shared';
import apiClient from './client';
/** PAD-06: what the paddlers decided about the obstacles of a paddle tour; answers the detail with its checklist. */
export const tourObstacleReviewsApi = {
save: (
tripId: number | string,
placeId: number | string,
body: TourObstacleReviewsRequest
): Promise<TourDetailResponse> =>
apiClient
.put(`/trips/${tripId}/tours/${placeId}/obstacle-reviews`, body)
.then((r) => tourDetailResponseSchema.parse(r.data)),
};
+20
View File
@@ -0,0 +1,20 @@
import type { TourDetailResponse, TourPackingLinkRequest, TourPackingLinkResponse } from '@trek/shared';
import apiClient from './client';
/**
* Where a tour's packing link lives; a write queued offline replays to the
* same path, `{id}` standing for a tour that has not synced yet.
*/
export function tourPackingLinkPath(tripId: number | string, placeId: number | string): string {
return `/trips/${tripId}/tours/${placeId}/packing-link`;
}
/** P2D-XD-01: the packing list, or the category of it, a tour points at. */
export const tourPackingLinkApi = {
read: (tripId: number | string, placeId: number, signal?: AbortSignal): Promise<TourPackingLinkResponse> =>
apiClient.get(tourPackingLinkPath(tripId, placeId), { signal }).then((r) => r.data),
link: (tripId: number | string, placeId: number, body: TourPackingLinkRequest): Promise<TourDetailResponse> =>
apiClient.put(tourPackingLinkPath(tripId, placeId), body).then((r) => r.data),
unlink: (tripId: number | string, placeId: number): Promise<TourDetailResponse> =>
apiClient.delete(tourPackingLinkPath(tripId, placeId)).then((r) => r.data),
};
+12
View File
@@ -0,0 +1,12 @@
import type { TourDetailResponse, TourPaddlePlanRequest } from '@trek/shared';
import apiClient from './client';
/** PAD-03/PAD-07: the portages, locks and speeds laid onto a recorded paddle track. */
export const tourPaddlePlanApi = {
update: (
tripId: number | string,
placeId: number | string,
body: TourPaddlePlanRequest
): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}/paddle-plan`, body).then((r) => r.data),
};
+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),
};
+11
View File
@@ -0,0 +1,11 @@
import type { TourDetailResponse, TourSkiClassificationRequest } from '@trek/shared';
import apiClient from './client';
/** SKI-02: a ski track classified again, its discipline and cross-country technique. */
export const tourSkiApi = {
classify: (
tripId: number | string,
placeId: number | string,
body: TourSkiClassificationRequest
): Promise<TourDetailResponse> => apiClient.put(`/trips/${tripId}/tours/${placeId}/ski`, body).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),
};
+20
View File
@@ -0,0 +1,20 @@
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
import apiClient from './client';
/** Where one station of a walk is marked for one day; a write queued offline replays to the same path. */
export function tourVisitPath(tripId: number | string, assignmentId: number, stationKey: string): string {
return `/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`;
}
/** CW-03: the stations of a trip's city walks visited or skipped on their days. */
export const tourVisitsApi = {
list: (tripId: number | string): Promise<TourVisitListResponse> =>
apiClient.get(`/trips/${tripId}/tour-visits`).then((r) => r.data),
update: (
tripId: number | string,
assignmentId: number,
stationKey: string,
body: TourVisitUpdateRequest
): Promise<TourVisitListResponse> =>
apiClient.put(tourVisitPath(tripId, assignmentId, stationKey), body).then((r) => r.data),
};
+20
View File
@@ -0,0 +1,20 @@
import type { WalkImportPreviewRequest, WalkImportPreviewResponse } from '@trek/shared';
import apiClient from './client';
/**
* CW-07: a Google Maps walking route read into its stops. The server may
* follow a short link a few hops within its own deadline, so the call waits
* a little longer than that before it gives up.
*/
const WALK_IMPORT_TIMEOUT_MS = 20_000;
export const tourWalkImportApi = {
preview: (
tripId: number | string,
body: WalkImportPreviewRequest,
signal?: AbortSignal
): Promise<WalkImportPreviewResponse> =>
apiClient
.post(`/trips/${tripId}/tours/import/google-maps/preview`, body, { signal, timeout: WALK_IMPORT_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),
};
+62
View File
@@ -0,0 +1,62 @@
import type {
AdminFeatureToggleResponse,
TourSourceCheckResponse,
TourSourceHintResponse,
WandererConnection,
WandererImportPreview,
WandererImportRequest,
WandererImportResponse,
WandererSaveResponse,
WandererSearchRequest,
WandererSearchResponse,
WandererSettingsRequest,
WandererTestRequest,
WandererTestResponse,
WandererWatch,
} from '@trek/shared';
import apiClient from './client';
/**
* How long the browser waits for a call that makes the server ask wanderer.
* A preview reads the trail's index entry, its record and its GPX one after
* another, each allowed 15 s on the server, so the shared 8 s would give up
* while the server carries on.
*/
const WANDERER_UPSTREAM_TIMEOUT_MS = 60_000;
const upstream = { timeout: WANDERER_UPSTREAM_TIMEOUT_MS };
export const wandererApi = {
getSettings: (): Promise<WandererConnection> => apiClient.get('/integrations/wanderer/settings').then((r) => r.data),
saveSettings: (data: WandererSettingsRequest): Promise<WandererSaveResponse> =>
apiClient.put('/integrations/wanderer/settings', data).then((r) => r.data),
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
/** WAN-02: the optional background watch of the connection, with the open hints. */
getWatch: (): Promise<WandererWatch> => apiClient.get('/integrations/wanderer/watch').then((r) => r.data),
setWatch: (enabled: boolean): Promise<WandererWatch> =>
apiClient.put('/integrations/wanderer/watch', { enabled }).then((r) => r.data),
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
preview: (tripId: number, trailId: string, signal?: AbortSignal): Promise<WandererImportPreview> =>
apiClient
.get(`/trips/${tripId}/tours/wanderer/trails/${encodeURIComponent(trailId)}`, { ...upstream, signal })
.then((r) => r.data),
importTrail: (tripId: number, body: WandererImportRequest): Promise<WandererImportResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/import`, body, upstream).then((r) => r.data),
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
/** WAN-02: the caller's own background hint on a snapshot; the server only reads its own rows. */
sourceHint: (tripId: number, placeId: number, signal?: AbortSignal): Promise<TourSourceHintResponse> =>
apiClient.get(`/trips/${tripId}/tours/wanderer/sources/${placeId}/hint`, { signal }).then((r) => r.data),
};
/** The admin's switch for wanderer under the Tours addon. */
export const wandererAdminApi = {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get('/admin/tours-wanderer').then((r) => r.data),
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put('/admin/tours-wanderer', { enabled }).then((r) => r.data),
};
+10
View File
@@ -0,0 +1,10 @@
import { windForecastResponseSchema, type WindForecastResponse } from '@trek/shared';
import apiClient from './client';
/** PAD-08: the wind forecast for one place and one day. */
export const windApi = {
forecast: (lat: number, lng: number, date: string, signal?: AbortSignal): Promise<WindForecastResponse> =>
apiClient
.get('/weather/wind', { params: { lat, lng, date }, signal })
.then((r) => windForecastResponseSchema.parse(r.data)),
};
+6
View File
@@ -19,6 +19,8 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
'tours:changed',
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
'tours:visits',
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
'collab:note:updated',
@@ -93,6 +95,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events.
'docsync:changed',
// Linked journal: components/Trips/useTripJournalLink asks again which journal
// the trip shows and who it is open to. Content-free, because that answer is
// per person (a member left off the link must not learn its title).
'trip:journal:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
@@ -1,4 +1,3 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-039
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
@@ -56,6 +55,12 @@ function isOn(toggle: HTMLElement): boolean {
return toggle.getAttribute('aria-pressed') === 'true';
}
/** A shelf of more than one row starts folded; open every one so its rows can be read. */
async function unfoldShelves(): Promise<void> {
const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false });
for (const bar of bars) await userEvent.click(bar);
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -72,7 +77,8 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { dark_mode: false } });
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
);
});
@@ -228,6 +234,7 @@ describe('AddonManager', () => {
)
);
render(<AddonManager />);
await unfoldShelves();
// Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash');
@@ -293,6 +300,7 @@ describe('AddonManager', () => {
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
@@ -427,6 +435,8 @@ describe('AddonManager', () => {
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Paperless-ngx');
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
@@ -524,6 +534,8 @@ describe('AddonManager', () => {
/>,
);
await unfoldShelves();
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
expect(screen.getByText('Polls')).toBeInTheDocument();
@@ -551,8 +563,9 @@ describe('AddonManager', () => {
<AddonManager
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
onToggleCollabFeature={vi.fn()}
/>,
/>
);
await unfoldShelves();
const links = await screen.findByText('Links');
expect(links).toHaveAttribute('title');
@@ -646,6 +659,8 @@ describe('AddonManager', () => {
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
// A gateway can stand in for Anthropic too, so the field stays (#2655).
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
});
@@ -837,4 +852,89 @@ describe('AddonManager', () => {
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: false, links: true, polls: false, whatsnext: false }}
onToggleCollabFeature={vi.fn()}
/>
);
const bar = await screen.findByRole('button', { name: /5 options/, expanded: false });
expect(bar).toHaveTextContent('2 on');
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
await userEvent.click(bar);
expect(bar).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Chat')).toBeInTheDocument();
await userEvent.click(bar);
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => {
server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Lists', icon: 'ListChecks', enabled: true })]));
render(<AddonManager bagTrackingEnabled onToggleBagTracking={vi.fn()} />);
expect(await screen.findByRole('button', { name: 'Bag Tracking' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-042: wanderer hangs off Tours and saves its switch', async () => {
const puts: unknown[] = [];
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.put('/api/admin/tours-wanderer', async ({ request }) => {
puts.push(await request.json());
return HttpResponse.json({ enabled: true });
})
);
render(
<>
<AddonManager />
<ToastContainer />
</>
);
const toggle = await screen.findByRole('button', { name: 'wanderer' });
expect(isOn(toggle)).toBe(false);
await userEvent.click(toggle);
await waitFor(() => expect(puts).toEqual([{ enabled: true }]));
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
});
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.put('/api/admin/tours-wanderer', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
);
render(
<>
<AddonManager />
<ToastContainer />
</>
);
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
});
it('FE-ADMIN-ADDON-044: Tours and Road trip show their own icons, not the fallback puzzle', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: false }),
buildAddon({ id: 'roadtrip', name: 'Road trip', icon: 'Route', enabled: false }),
])
);
render(<AddonManager />);
const tours = (await screen.findByText('Tours')).closest('article') as HTMLElement;
const roadtrip = screen.getByText('Road trip').closest('article') as HTMLElement;
expect(tours.querySelector('svg.lucide-mountain')).toBeInTheDocument();
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
});
});
+37 -9
View File
@@ -1,7 +1,7 @@
import { useId, type ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
import EmptyState from '../shared/EmptyState'
@@ -14,16 +14,20 @@ import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
import { useAddonManager } from './addons/useAddonManager'
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
import WandererMark from '../Tours/wanderer/WandererMark'
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
// used to land on the fallback.
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
Mountain,
// Road trip was seeded as 'Route'; the car says what it is better than a line does.
Route: CarFront,
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
// providers below do. Keyed by the string in the addons table.
Dawarich: DawarichIcon,
@@ -71,6 +75,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
if (loading) {
@@ -147,6 +154,29 @@ 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()}
/>
{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,
// just as a form instead of toggle rows.
if (addon.id === 'llm_parsing' && addon.enabled) {
@@ -265,7 +295,7 @@ const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py
function LlmParsingConfig({ addon }: { addon: Addon }) {
const { t } = useTranslation()
const {
provider, setProvider, model, setModel, baseUrl, setBaseUrl, apiKey, setApiKey, vision, setVision, saving,
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
} = useLlmParsingConfig(addon)
const fieldId = useId()
@@ -288,15 +318,13 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
<EditorField label="Provider">
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</EditorField>
{provider !== 'anthropic' && (
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</EditorField>
)}
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
</EditorField>
<EditorField
label="API key"
htmlFor={`${fieldId}-key`}
hint={provider === 'anthropic' ? 'Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.' : undefined}
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
>
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</EditorField>
+50 -3
View File
@@ -1,6 +1,13 @@
import type { ReactNode } from 'react'
import { Children, type ReactNode, isValidElement, useId, useState } from 'react'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { fs } from '../shared/DialogShell'
import { ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
/** How many of the shelf's rows are switched on, read from the rows' own `enabled`. */
function enabledRows(rows: ReactNode[]): number {
return rows.filter((row) => isValidElement<{ enabled?: boolean }>(row) && row.props.enabled === true).length
}
/**
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
@@ -14,6 +21,10 @@ import { fs } from '../shared/DialogShell'
*
* The row is not a click target, only the switches are. No lift, no pointer
* cursor, no `role="button"`.
*
* A shelf of more than one row starts folded behind a bar that says how many
* options there are and how many are on, so a page of addons stays short and
* the options are still plain to see.
*/
export default function AddonTile({
icon,
@@ -31,6 +42,13 @@ export default function AddonTile({
/** The sub-shelf: `<AddonSubRow>` children. */
children?: ReactNode
}) {
const { t } = useTranslation()
const shelfId = useId()
const [open, setOpen] = useState(false)
const rows = Children.toArray(children)
const foldable = rows.length > 1
const active = enabledRows(rows)
return (
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
<div className="flex items-start gap-3">
@@ -62,11 +80,40 @@ export default function AddonTile({
</div>
</div>
{children && (
{rows.length > 0 && (
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
{foldable && (
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={shelfId}
className={`flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent ${open ? 'border-b border-edge-faint' : ''}`}
>
{/* The same slot and type as a row below, so the bar reads as one more row. */}
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<ChevronDown
size={15}
strokeWidth={2.2}
className={`transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.subOptions', { count: rows.length })}
</span>
<span
className={`flex-none rounded-full px-2 py-0.5 font-semibold ${active > 0 ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}
style={fs(10.5, 'body')}
>
{t('admin.addons.subOptionsOn', { count: active })}
</span>
</button>
)}
{/* Sitting inside the parent on its own fill is enough to say these belong
to it: no rail, no indent guides. */}
<ul className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
{(!foldable || open) && (
<ul id={shelfId} className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
)}
</div>
)}
</article>
@@ -64,6 +64,19 @@ export function getAddonLabel(t: (key: string) => string, addon: Addon): { name:
export const MASKED = '••••••••';
export const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1';
/** What an empty Base URL stands for with each provider, shown as its placeholder. */
export const LLM_BASE_URL_PLACEHOLDER: Record<string, string> = {
local: DEFAULT_OLLAMA_URL,
openai: 'https://api.openai.com/v1',
// The Anthropic client appends /v1/messages itself, so a gateway is given without /v1.
anthropic: 'https://api.anthropic.com',
};
/** Under the Anthropic key: what only Anthropic can read, and what a gateway in its place needs (#2655). */
export const ANTHROPIC_HINT =
'Anthropic reads PDFs (including scans) natively. Local and OpenAI models receive extracted text, so scanned PDFs need Anthropic. ' +
'A gateway in place of api.anthropic.com goes in Base URL without /v1 and has to accept PDF document blocks and forced tool calls.';
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
@@ -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,9 @@
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';
import { useToast } from '../../shared/Toast';
@@ -18,6 +21,9 @@ export function useAddonManager() {
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
const [toursHikingMap, setToursHikingMap] = useState(false);
const [toursCycleMap, setToursCycleMap] = useState(false);
const loadAddons = async () => {
try {
@@ -30,6 +36,18 @@ export function useAddonManager() {
const loadOnMount = useEffectEvent(() => {
void loadAddons().finally(() => setLoading(false));
wandererAdminApi
.get()
.then((flag) => setToursWanderer(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
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();
@@ -57,6 +75,45 @@ export function useAddonManager() {
toast.success(t('admin.addons.toast.updated'));
};
/** 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 save(next);
} catch {
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,
label: provider.name,
@@ -83,5 +140,8 @@ export function useAddonManager() {
integrationAddons,
providerOptions,
documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
};
}
@@ -52,15 +52,19 @@ describe('useLlmParsingConfig', () => {
expect(result.current.isInstalled('llama3')).toBe(false);
});
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing until switched to local, then uses the typed URL', async () => {
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
);
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
await act(() => result.current.loadModels());
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
// The OpenAI address does not follow to another provider (#2655).
act(() => result.current.setProvider('local'));
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith('http://box:11434/v1'));
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
act(() => result.current.setBaseUrl(' http://box:11434/v1 '));
await act(() => result.current.loadModels());
expect(adminApi.llmLocalModels).toHaveBeenLastCalledWith('http://box:11434/v1');
});
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
@@ -148,7 +152,7 @@ describe('useLlmParsingConfig', () => {
expect(result.current.saving).toBe(false);
});
it('FE-HOOK-LLMCFG-009: Anthropic is saved with an empty base URL, whatever was typed before', async () => {
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
act(() => {
result.current.setProvider('anthropic');
@@ -160,6 +164,50 @@ describe('useLlmParsingConfig', () => {
});
});
it('FE-HOOK-LLMCFG-009b: an Anthropic gateway URL survives a save (#2655)', async () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'gateway-model', baseUrl: 'http://gateway.lan:4000' }))
);
expect(result.current.baseUrl).toBe('http://gateway.lan:4000');
await act(() => result.current.save());
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({
provider: 'anthropic',
baseUrl: 'http://gateway.lan:4000',
});
});
it('FE-HOOK-LLMCFG-009c: a URL typed after picking Anthropic is saved, trimmed', async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
act(() => result.current.setProvider('anthropic'));
act(() => result.current.setBaseUrl(' http://gw:4000 '));
await act(() => result.current.save());
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({ baseUrl: 'http://gw:4000' });
});
it('FE-HOOK-LLMCFG-009d: away and back again brings the saved URL back; staying put keeps a typed one', () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'anthropic', baseUrl: 'http://gateway.lan:4000' }))
);
act(() => result.current.setProvider('local'));
expect(result.current.baseUrl).toBe('');
act(() => result.current.setProvider('openai'));
expect(result.current.baseUrl).toBe('');
act(() => result.current.setProvider('anthropic'));
expect(result.current.baseUrl).toBe('http://gateway.lan:4000');
act(() => result.current.setBaseUrl('http://typed:4000'));
act(() => result.current.setProvider('anthropic'));
expect(result.current.baseUrl).toBe('http://typed:4000');
});
it('FE-HOOK-LLMCFG-009e: the placeholder names the address an empty field stands for', () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
expect(result.current.baseUrlPlaceholder).toBe('http://localhost:11434/v1');
act(() => result.current.setProvider('openai'));
expect(result.current.baseUrlPlaceholder).toBe('https://api.openai.com/v1');
act(() => result.current.setProvider('anthropic'));
expect(result.current.baseUrlPlaceholder).toBe('https://api.anthropic.com');
});
it('FE-HOOK-LLMCFG-010: a failed save toasts and clears saving', async () => {
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
@@ -4,7 +4,7 @@ import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel';
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
/**
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
@@ -15,7 +15,7 @@ import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel';
export function useLlmParsingConfig(addon: Addon) {
const toast = useToast();
const cfg = addon.config ?? {};
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local');
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
@@ -30,6 +30,18 @@ export function useLlmParsingConfig(addon: Addon) {
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
/**
* A different provider never inherits the last one's endpoint: an Ollama address
* left in the field would quietly receive the OpenAI or Anthropic key. Back on the
* saved provider, its saved address returns. Any provider, Anthropic included, may
* keep an address the admin typed for it (#2655); empty means the provider default.
*/
const setProvider = (next: string) => {
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
setProviderState(next);
};
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
@@ -89,7 +101,7 @@ export function useLlmParsingConfig(addon: Addon) {
config: {
provider,
model: model.trim(),
baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(),
baseUrl: baseUrl.trim(),
apiKey,
vision,
},
@@ -109,6 +121,7 @@ export function useLlmParsingConfig(addon: Addon) {
setModel,
baseUrl,
setBaseUrl,
baseUrlPlaceholder,
apiKey,
setApiKey,
vision,
@@ -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>
);
}
@@ -0,0 +1,411 @@
// 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';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { isReading, progressPercent, regionStatusTone } from './paddleRegionsModel';
import PaddleRegionsPanel from './PaddleRegionsPanel';
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
const hook = vi.hoisted(() => ({ usePaddleRegionsAdmin: vi.fn<() => Admin>() }));
vi.mock('./usePaddleRegionsAdmin', () => ({ usePaddleRegionsAdmin: hook.usePaddleRegionsAdmin }));
const HINT = 'Weirs, locks and paddling rules from OpenStreetMap, read per region and again every month.';
const ATTRIBUTION =
'Data © OpenStreetMap contributors (ODbL), regional extracts by Geofabrik. Read again on the 3rd of every month.';
const EMPTY = 'No region yet. Paddle tours then show that no obstacle data is known.';
const CATALOG_DOWN = 'Geofabrik cannot be reached right now. Try again later.';
/** A region read once, its data current to the 3rd of October (midday, so every time zone keeps the day). */
function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
return {
id: 'oberpfalz',
name: 'Oberpfalz',
status: 'ready',
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,
};
}
/** A region before its first read: no data and no date yet. */
const unread = (overrides: Partial<PaddleRegion>): PaddleRegion =>
region({ source_timestamp: null, imported_at: null, feature_count: 0, ...overrides });
const READY = region();
const WAITING = unread({ id: 'donau', name: 'Donau', status: 'queued' });
const FAILED_AGAIN = region({ id: 'saale', name: 'Saale', status: 'failed', error: 'Download broke off.' });
const FAILED_FIRST = unread({ id: 'mosel', name: 'Mosel', status: 'failed', error: 'Not a PBF file.' });
/** Geofabrik's answers for "Limburg": the plain names told apart by the regions they lie in. */
const HITS: PaddleRegionCatalogEntry[] = [
{ id: 'germany/hessen/limburg-weilburg', name: 'Limburg-Weilburg', path: ['Europe', 'Germany', 'Hessen'] },
{ id: 'netherlands/limburg', name: 'Limburg', path: ['Europe', 'Netherlands'] },
{ id: 'belgium/limburg', name: 'Limburg', path: ['Europe', 'Belgium'] },
{ id: 'limburg', name: 'Limburg', path: [] },
];
function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['search']> = {}): Admin {
return {
toursOn: true,
offline: false,
regions: [],
requests: [],
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
loading: false,
error: null,
retry: vi.fn(),
added: new Set<string>(),
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(),
confirmRemove: vi.fn(),
...overrides,
search: { query: '', setQuery: vi.fn(), hits: null, searching: false, error: null, ...search },
};
}
function show(overrides: Partial<Admin> = {}, search: Partial<Admin['search']> = {}) {
const state = adminState(overrides, search);
hook.usePaddleRegionsAdmin.mockReturnValue(state);
return { state, ...render(<PaddleRegionsPanel />) };
}
/** The card's title row, where the number of regions sits beside the title. */
function titleRow(): HTMLElement {
const row = screen.getByRole('heading', { name: 'Paddle water data' }).parentElement;
if (!row) throw new Error('no title row');
return row;
}
function rowOf(name: string): HTMLElement {
const row = screen.getByText(name).closest<HTMLElement>('[data-testid="paddle-region-row"]');
if (!row) throw new Error(`no row for ${name}`);
return row;
}
describe('PaddleRegionsPanel', () => {
it('FE-ADMIN-PADDLEREG-001: renders nothing while the Tours addon is off', () => {
const { container } = show({ toursOn: false, regions: [READY] });
expect(container).toBeEmptyDOMElement();
});
it('FE-ADMIN-PADDLEREG-002: shows the card with its hint, the attribution and the empty state', () => {
show();
expect(screen.getByRole('heading', { name: 'Paddle water data' })).toBeInTheDocument();
expect(screen.getByText(HINT)).toBeInTheDocument();
expect(screen.getByText(EMPTY)).toBeInTheDocument();
expect(screen.getByText(ATTRIBUTION)).toBeInTheDocument();
// No count beside the title while there is no region.
expect(titleRow()).toHaveTextContent(/^Paddle water data$/);
const search = screen.getByRole('searchbox', { name: 'Add a region' });
expect(search).toBeEnabled();
expect(search).toHaveAttribute('placeholder', 'Search Geofabrik, for example Oberpfalz');
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-region-row')).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-003: says the list is loading instead of calling it empty', () => {
show({ loading: true });
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-004: offline it says why and locks the search and every row, without the list error', () => {
show({ offline: true, error: 'Network Error', regions: [READY] });
expect(screen.getByRole('status')).toHaveTextContent('Regions can only be managed with a connection.');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByText('Network Error')).not.toBeInTheDocument();
expect(screen.getByRole('searchbox', { name: 'Add a region' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Read again Oberpfalz' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Remove region Oberpfalz' })).toBeDisabled();
});
it('FE-ADMIN-PADDLEREG-005: a failed list read shows its error with a retry that asks the hook again', () => {
const { state } = show({ error: 'Server unreachable' });
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Server unreachable');
fireEvent.click(within(alert).getByRole('button', { name: 'Try again' }));
expect(state.retry).toHaveBeenCalledTimes(1);
});
it('FE-ADMIN-PADDLEREG-006: the search shows the query and hands every change to the hook', () => {
const { state } = show({}, { query: 'Lim' });
const search = screen.getByRole('searchbox', { name: 'Add a region' });
expect(search).toHaveValue('Lim');
fireEvent.change(search, { target: { value: 'Limb' } });
expect(state.search.setQuery).toHaveBeenCalledWith('Limb');
});
it('FE-ADMIN-PADDLEREG-007: while Geofabrik is searched it says so and shows no verdict yet', () => {
show({}, { query: 'Limburg', searching: true });
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
expect(screen.queryByText('No region by that name')).not.toBeInTheDocument();
expect(screen.queryByTestId('paddle-region-hit')).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-008: a failed search shows its error under the field, without the list retry', () => {
show({}, { query: 'Limburg', error: CATALOG_DOWN });
expect(screen.getByRole('alert')).toHaveTextContent(CATALOG_DOWN);
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-009: a search without hits says no region has that name', () => {
show({}, { query: 'Atlantis', hits: [] });
expect(screen.getByText('No region by that name')).toBeInTheDocument();
expect(screen.queryByTestId('paddle-region-hit')).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-010: hits name the regions they lie in, outermost first, and mark the ones added', () => {
show({ added: new Set(['germany/hessen/limburg-weilburg']) }, { query: 'Limburg', hits: HITS });
const [weilburg, dutch, belgian, bare] = screen.getAllByTestId('paddle-region-hit');
const path = within(weilburg).getAllByText(/^(Europe|Germany|Hessen)$/);
expect(path.map((part) => part.textContent)).toEqual(['Europe', 'Germany', 'Hessen']);
expect(within(weilburg).getByText('Added')).toBeInTheDocument();
expect(within(weilburg).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
expect(within(dutch).getByText('Netherlands')).toBeInTheDocument();
expect(within(dutch).getByRole('button', { name: 'Add' })).toBeEnabled();
expect(within(belgian).getByText('Belgium')).toBeInTheDocument();
// A region at the top of Geofabrik's tree has nothing above it to name.
expect(within(bare).getByText('Limburg').nextElementSibling).toBeNull();
});
it('FE-ADMIN-PADDLEREG-011: adding a hit hands its Geofabrik id to the hook', () => {
const { state } = show({}, { query: 'Limburg', hits: HITS });
const dutch = screen.getAllByTestId('paddle-region-hit')[1];
fireEvent.click(within(dutch).getByRole('button', { name: 'Add' }));
expect(state.add).toHaveBeenCalledWith('netherlands/limburg');
});
it('FE-ADMIN-PADDLEREG-012: while one region is added every add waits, and the one under way spins', () => {
show({ busyId: 'netherlands/limburg' }, { query: 'Limburg', hits: HITS });
const [, dutch, belgian] = screen.getAllByTestId('paddle-region-hit');
const adding = within(dutch).getByRole('button', { name: 'Add' });
const waiting = within(belgian).getByRole('button', { name: 'Add' });
expect(adding).toBeDisabled();
expect(waiting).toBeDisabled();
expect(adding.querySelector('.animate-spin')).not.toBeNull();
expect(waiting.querySelector('.animate-spin')).toBeNull();
});
it('FE-ADMIN-PADDLEREG-013: a waiting region reads Waiting, has no data yet and cannot be read again', () => {
show({ regions: [WAITING] });
const row = rowOf('Donau');
expect(within(row).getByText('Waiting')).toHaveClass('border-edge-faint');
expect(within(row).queryByText(/objects?$/)).not.toBeInTheDocument();
expect(within(row).queryByText(/^As of/)).not.toBeInTheDocument();
expect(within(row).getByRole('button', { name: 'Read again Donau' })).toBeDisabled();
expect(within(row).getByRole('button', { name: 'Remove region Donau' })).toBeEnabled();
});
it('FE-ADMIN-PADDLEREG-014: a region being read shows its phase and share, the phase alone while the size is unknown', () => {
show({
regions: [
unread({ id: 'elbe', name: 'Elbe', status: 'importing', progress: { phase: 'download', fraction: 0.416 } }),
unread({ id: 'main', name: 'Main', status: 'importing', progress: { phase: 'read', fraction: 0 } }),
unread({ id: 'isar', name: 'Isar', status: 'importing', progress: { phase: 'download', fraction: null } }),
unread({ id: 'naab', name: 'Naab', status: 'importing' }),
],
});
const elbe = rowOf('Elbe');
expect(within(elbe).getByText('Downloading 42%')).toHaveClass('bg-accent');
expect(within(elbe).getByRole('button', { name: 'Read again Elbe' })).toBeDisabled();
expect(within(elbe).getByRole('button', { name: 'Remove region Elbe' })).toBeEnabled();
// A read just begun says 0 %, not nothing.
expect(within(rowOf('Main')).getByText('Reading 0%')).toBeInTheDocument();
expect(within(rowOf('Isar')).getByText('Downloading')).toBeInTheDocument();
expect(within(rowOf('Naab')).getByText('Reading')).toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-015: a ready region shows its object count and the date its data is current to', () => {
show({ regions: [READY, region({ id: 'regen', name: 'Regen', feature_count: 1, source_timestamp: null })] });
const row = rowOf('Oberpfalz');
expect(within(row).getByText('Ready')).toHaveClass('bg-success-soft');
expect(within(row).getByText('1234 objects')).toBeInTheDocument();
expect(within(row).getByText('As of Oct 3, 2026')).toBeInTheDocument();
expect(within(row).getByRole('button', { name: 'Read again Oberpfalz' })).toBeEnabled();
// One object reads singular; without a source date there is no date to show.
const regen = rowOf('Regen');
expect(within(regen).getByText('1 object')).toBeInTheDocument();
expect(within(regen).queryByText(/^As of/)).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-016: a failed read shows its error, and that the earlier data stays in use when there is some', () => {
show({ regions: [FAILED_AGAIN, FAILED_FIRST] });
const again = rowOf('Saale');
expect(within(again).getByText('Failed')).toHaveClass('bg-danger-soft');
const error = within(again).getByText('Download broke off. The data read before stays in use.');
expect(error).toHaveAttribute('title', 'Download broke off.');
const first = rowOf('Mosel');
expect(within(first).getByText('Not a PBF file.')).toBeInTheDocument();
expect(within(first).queryByText(/stays in use/)).not.toBeInTheDocument();
expect(within(first).queryByText(/objects?$/)).not.toBeInTheDocument();
expect(within(first).getByRole('button', { name: 'Read again Mosel' })).toBeEnabled();
});
it('FE-ADMIN-PADDLEREG-017: Read again and Remove hand the region to the hook', () => {
const { state } = show({ regions: [READY] });
fireEvent.click(screen.getByRole('button', { name: 'Read again Oberpfalz' }));
expect(state.refresh).toHaveBeenCalledWith('oberpfalz');
fireEvent.click(screen.getByRole('button', { name: 'Remove region Oberpfalz' }));
expect(state.askRemove).toHaveBeenCalledWith(READY);
// The question opens only once the hook holds the region to remove.
expect(screen.queryByRole('heading', { name: 'Remove region' })).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-018: while one region is busy its row dims and no row can be read again or removed', () => {
show({ regions: [READY, FAILED_AGAIN], busyId: 'oberpfalz' });
expect(rowOf('Oberpfalz')).toHaveClass('opacity-60');
expect(rowOf('Saale')).not.toHaveClass('opacity-60');
for (const name of ['Oberpfalz', 'Saale']) {
expect(screen.getByRole('button', { name: `Read again ${name}` })).toBeDisabled();
expect(screen.getByRole('button', { name: `Remove region ${name}` })).toBeDisabled();
}
});
it('FE-ADMIN-PADDLEREG-019: the title carries the number of regions', () => {
show({ regions: [READY, WAITING, FAILED_FIRST] });
expect(within(titleRow()).getByText('3')).toBeInTheDocument();
expect(screen.getAllByTestId('paddle-region-row')).toHaveLength(3);
});
it('FE-ADMIN-PADDLEREG-020: removing asks first, naming the region, and confirming hands it to the hook', () => {
const { state } = show({ regions: [READY], removing: READY });
const message = 'Oberpfalz and its obstacle data are removed. Paddle tours there then show no data.';
expect(screen.getByRole('heading', { name: 'Remove region' })).toBeInTheDocument();
expect(screen.getByText(message)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
expect(state.confirmRemove).toHaveBeenCalledTimes(1);
});
it('FE-ADMIN-PADDLEREG-021: Cancel and Escape take the question back without removing', () => {
const { state } = show({ regions: [READY], removing: READY });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
fireEvent.keyDown(document, { key: 'Escape' });
expect(state.cancelRemove).toHaveBeenCalledTimes(2);
expect(state.confirmRemove).not.toHaveBeenCalled();
});
});
describe('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);
expect(isReading({ status: 'importing' })).toBe(true);
expect(isReading({ status: 'ready' })).toBe(false);
expect(isReading({ status: 'failed' })).toBe(false);
});
it('FE-ADMIN-PADDLEREG-MODEL-002: the share of a read is a whole percent, null while its size is unknown', () => {
expect(progressPercent({ progress: null })).toBeNull();
expect(progressPercent({ progress: { phase: 'download', fraction: null } })).toBeNull();
expect(progressPercent({ progress: { phase: 'download', fraction: 0 } })).toBe(0);
expect(progressPercent({ progress: { phase: 'read', fraction: 0.416 } })).toBe(42);
expect(progressPercent({ progress: { phase: 'read', fraction: 1 } })).toBe(100);
});
it('FE-ADMIN-PADDLEREG-MODEL-003: the badge tone follows the state of the read, never the water', () => {
expect(regionStatusTone('queued')).toBe('neutral');
expect(regionStatusTone('importing')).toBe('accent');
expect(regionStatusTone('ready')).toBe('success');
expect(regionStatusTone('failed')).toBe('danger');
});
});
@@ -0,0 +1,320 @@
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { Check, ChevronRight, Loader2, Plus, RefreshCw, Search, Trash2, Waves, WifiOff } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import {
SettingRows,
SETTINGS_BUTTON,
SETTINGS_ICON_BUTTON,
SettingsCard,
SettingsHint,
StatusPill,
} from '../../Settings/settingsKit';
import ConfirmDialog from '../../shared/ConfirmDialog';
import { EditorField, INPUT } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import ErrorBoundary from '../../shared/ErrorBoundary';
import { Tooltip } from '../../shared/Tooltip';
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
/**
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
* picked from Geofabrik's list, 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 (
<ErrorBoundary boundaryId="paddle-regions">
<Panel />
</ErrorBoundary>
);
}
function Panel() {
const { t } = useTranslation();
const admin = usePaddleRegionsAdmin();
if (!admin.toursOn) return null;
return (
<SettingsCard
icon={Waves}
title={t('admin.paddleRegions.title')}
hint={t('admin.paddleRegions.hint')}
badge={admin.regions.length > 0 ? <StatusPill>{admin.regions.length}</StatusPill> : undefined}
>
{admin.offline && (
<p
role="status"
className="m-0 flex items-center gap-2 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-warning"
style={fs(12.5, 'body')}
>
<WifiOff size={14} className="flex-none" />
{t('admin.paddleRegions.offline')}
</p>
)}
{admin.error && !admin.offline && (
<div
role="alert"
className="flex flex-wrap items-center gap-3 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger"
style={fs(12.5, 'body')}
>
<span className="min-w-0 flex-1 basis-48">{admin.error}</span>
<button type="button" onClick={admin.retry} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
{t('admin.paddleRegions.retry')}
</button>
</div>
)}
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
<AutoLoadSettings admin={admin} />
<RequestList admin={admin} />
<CatalogSearch admin={admin} />
<RegionList admin={admin} />
</fieldset>
<SettingsHint>{t('admin.paddleRegions.attribution')}</SettingsHint>
<ConfirmDialog
isOpen={admin.removing !== null}
onClose={admin.cancelRemove}
onConfirm={admin.confirmRemove}
title={t('admin.paddleRegions.remove')}
message={t('admin.paddleRegions.removeMessage', { name: admin.removing?.name ?? '' })}
confirmLabel={t('common.remove')}
/>
</SettingsCard>
);
}
/** A Geofabrik region with the regions it lies in, outermost first, so two of one name tell apart. */
function RegionPath({ hit }: { hit: PaddleRegionCatalogEntry }) {
if (hit.path.length === 0) return null;
return (
<span className="flex min-w-0 flex-wrap items-center gap-0.5 text-content-faint" style={fs(11.5)}>
{hit.path.map((part, index) => (
<span key={`${part}-${index}`} className="inline-flex items-center gap-0.5">
{index > 0 && <ChevronRight size={11} className="flex-none rtl:rotate-180" aria-hidden="true" />}
{part}
</span>
))}
</span>
);
}
function CatalogSearch({ admin }: { admin: Admin }) {
const { t } = useTranslation();
const id = useId();
const { search } = admin;
return (
<div className="flex flex-col gap-2">
<EditorField label={t('admin.paddleRegions.search')} htmlFor={id}>
<div className="relative">
<Search
size={14}
className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint"
aria-hidden="true"
/>
<input
id={id}
type="search"
className={`${INPUT} ps-9`}
value={search.query}
onChange={(event) => search.setQuery(event.target.value)}
placeholder={t('admin.paddleRegions.searchPlaceholder')}
autoComplete="off"
/>
</div>
</EditorField>
{search.searching && (
<p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
<Loader2 size={14} className="flex-none animate-spin" />
{t('common.loading')}
</p>
)}
{search.error && (
<p role="alert" className="m-0 text-danger" style={fs(12.5, 'body')}>
{search.error}
</p>
)}
{search.hits && search.hits.length === 0 && <SettingsHint>{t('admin.paddleRegions.noHits')}</SettingsHint>}
{search.hits && search.hits.length > 0 && (
<SettingRows>
{search.hits.map((hit) => (
<div key={hit.id} className="flex items-center gap-3 py-2 pe-2 ps-3.5" data-testid="paddle-region-hit">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
{hit.name}
</span>
<RegionPath hit={hit} />
</div>
{admin.added.has(hit.id) ? (
<StatusPill icon={<Check size={11} strokeWidth={2.4} />}>{t('admin.paddleRegions.added')}</StatusPill>
) : (
<button
type="button"
disabled={admin.busyId !== null}
onClick={() => void admin.add(hit.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{admin.busyId === hit.id ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Plus size={13} strokeWidth={2.2} />
)}
{t('common.add')}
</button>
)}
</div>
))}
</SettingRows>
)}
</div>
);
}
function RegionList({ admin }: { admin: Admin }) {
const { t } = useTranslation();
if (admin.loading) {
return (
<p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
<Loader2 size={14} className="flex-none animate-spin" />
{t('common.loading')}
</p>
);
}
if (admin.regions.length === 0) {
return (
<div className="rounded-[12px] border border-dashed border-edge px-4 py-5 text-center">
<SettingsHint>{t('admin.paddleRegions.empty')}</SettingsHint>
</div>
);
}
return (
<SettingRows>
{admin.regions.map((region) => (
<RegionRow key={region.id} region={region} admin={admin} />
))}
</SettingRows>
);
}
function RegionStatus({ region }: { region: PaddleRegion }) {
const { t, language } = useTranslation();
const percent = progressPercent(region);
const label =
region.status === 'importing' && region.progress
? t(`admin.paddleRegions.phase.${region.progress.phase}`)
: t(`admin.paddleRegions.status.${region.status}`);
const share =
percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`;
return (
<StatusPill
tone={regionStatusTone(region.status)}
icon={region.status === 'importing' ? <Loader2 size={11} className="animate-spin" /> : undefined}
>
{label}
{share}
</StatusPill>
);
}
/**
* 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;
const date = region.source_timestamp
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
: null;
return (
<div
className={`flex items-center gap-3 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
data-testid="paddle-region-row"
>
<span
aria-hidden
className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary"
>
<Waves size={15} strokeWidth={1.9} />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
{region.name}
</span>
<span className="flex flex-wrap items-center gap-1.5">
<RegionStatus region={region} />
{region.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}>
{region.error}
{region.imported_at ? ` ${t('admin.paddleRegions.keptData')}` : ''}
</p>
)}
</div>
<Tooltip label={t('admin.paddleRegions.refresh')}>
<button
type="button"
aria-label={`${t('admin.paddleRegions.refresh')} ${region.name}`}
disabled={admin.busyId !== null || region.status === 'queued' || region.status === 'importing'}
onClick={() => void admin.refresh(region.id)}
className={SETTINGS_ICON_BUTTON}
>
<RefreshCw size={14} strokeWidth={2} />
</button>
</Tooltip>
<Tooltip label={t('admin.paddleRegions.remove')}>
<button
type="button"
aria-label={`${t('admin.paddleRegions.remove')} ${region.name}`}
disabled={admin.busyId !== null}
onClick={() => admin.askRemove(region)}
className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
>
<Trash2 size={14} strokeWidth={2} />
</button>
</Tooltip>
</div>
);
}
@@ -0,0 +1,20 @@
import type { PaddleRegion } from '@trek/shared';
/** A region waiting for its read or being read: the list asks again until none is. */
export function isReading(region: Pick<PaddleRegion, 'status'>): boolean {
return region.status === 'queued' || region.status === 'importing';
}
/** The share of a read under way as a whole percent, or null while its size is unknown. */
export function progressPercent(region: Pick<PaddleRegion, 'progress'>): number | null {
const fraction = region.progress?.fraction;
return fraction === null || fraction === undefined ? null : Math.round(fraction * 100);
}
/** The status badge's tone: what state the read is in, never whether the water is safe. */
export function regionStatusTone(status: PaddleRegion['status']): 'neutral' | 'accent' | 'success' | 'danger' {
if (status === 'importing') return 'accent';
if (status === 'ready') return 'success';
if (status === 'failed') return 'danger';
return 'neutral';
}
@@ -0,0 +1,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 };
}
@@ -0,0 +1,385 @@
/**
* FE-HOOK-PADDLEREGIONS-001 to FE-HOOK-PADDLEREGIONS-014: the paddle regions in
* the admin area (PAD-05A), the one hook the desktop and the phone admin render
* through PaddleRegionsPanel. The repo is mocked; its error codes and its
* offline error stay the real ones, and the strings are the English ones.
* FE-PADDLEREGIONSMODEL-001: the model's helpers for the list and its badges.
*/
import { act, renderHook } from '@testing-library/react';
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { createElement, type ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { PaddleRegionsOfflineError } from '../../../repo/paddleRegionRepo';
import { useAddonStore } from '../../../store/addonStore';
import { setForcedOffline } from '../../../sync/networkMode';
import { isReading, progressPercent, regionStatusTone } from './paddleRegionsModel';
import { CATALOG_SEARCH_DEBOUNCE_MS, PADDLE_REGIONS_POLL_MS, usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
const repo = vi.hoisted(() => ({
list: vi.fn<() => Promise<PaddleRegion[]>>(),
catalog: vi.fn<(q: string, signal?: AbortSignal) => Promise<PaddleRegionCatalogEntry[]>>(),
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
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')>()),
// 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 }));
const OFFLINE = 'Regions can only be managed with a connection.';
/** A region as the server lists it. */
function region(id: string, status: PaddleRegion['status'], over: Partial<PaddleRegion> = {}): PaddleRegion {
return {
id,
name: id,
status,
origin: 'admin',
source_timestamp: null,
imported_at: null,
feature_count: 0,
error: null,
progress: null,
network: null,
...over,
};
}
/** A region in Geofabrik's list. */
function hit(id: string, name: string, ...path: string[]): PaddleRegionCatalogEntry {
return { id, name, path };
}
const READY = region('oberbayern', 'ready', { source_timestamp: '2026-10-01T20:21:00Z', feature_count: 412 });
const FAILED = region('niederbayern', 'failed', { error: 'Download failed' });
const QUEUED = region('oberpfalz', 'queued');
const IMPORTING = region('oberpfalz', 'importing', { progress: { phase: 'read', fraction: 0.4 } });
const DONE = region('oberpfalz', 'ready', { source_timestamp: '2026-10-15T20:21:00Z', feature_count: 87 });
const HIT = hit('oberpfalz', 'Oberpfalz', 'Europe', 'Germany', 'Bayern');
const OBERBAYERN_HIT = hit('oberbayern', 'Oberbayern', 'Europe', 'Germany', 'Bayern');
/** An axios error as the server answers it: its message and, for some, a code. */
function apiError(error: string, code?: string) {
return { response: { data: { error, code } } };
}
/** A promise the test settles itself, to look at the hook while a call is out. */
function deferred<T>() {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((r) => (resolve = r));
return { promise, resolve };
}
/** Lets the clock run on and every answer due by then arrive. */
async function settle(ms = 0) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
async function mount() {
const hook = renderHook(() => usePaddleRegionsAdmin(), { wrapper });
await settle();
return hook;
}
function seedTours() {
seedStore(useAddonStore, { addons: [{ id: 'tours', enabled: true }] });
}
beforeEach(() => {
vi.useFakeTimers();
resetAllStores();
seedTours();
toast.success.mockReset();
toast.error.mockReset();
repo.list.mockReset().mockResolvedValue([READY]);
repo.catalog.mockReset().mockResolvedValue([HIT]);
repo.add.mockReset().mockResolvedValue(QUEUED);
repo.refresh.mockReset().mockResolvedValue(READY);
repo.remove.mockReset().mockResolvedValue({ success: true });
});
afterEach(() => {
act(() => setForcedOffline(false));
vi.useRealTimers();
});
describe('usePaddleRegionsAdmin', () => {
it('FE-HOOK-PADDLEREGIONS-001: asks nothing while the Tours addon is off, and loads once it is switched on', async () => {
seedStore(useAddonStore, { addons: [] });
const { result } = await mount();
await settle(PADDLE_REGIONS_POLL_MS * 2);
expect(result.current.toursOn).toBe(false);
expect(repo.list).not.toHaveBeenCalled();
act(() => seedTours());
await settle();
expect(result.current.toursOn).toBe(true);
expect(repo.list).toHaveBeenCalledTimes(1);
expect(result.current.regions).toEqual([READY]);
});
it('FE-HOOK-PADDLEREGIONS-002: loads the list, knows which regions are added and asks no more while none is read', async () => {
repo.list.mockResolvedValue([READY, FAILED]);
const { result } = renderHook(() => usePaddleRegionsAdmin(), { wrapper });
expect(result.current).toMatchObject({ toursOn: true, offline: false, loading: true, regions: [], error: null });
await settle();
expect(result.current).toMatchObject({ loading: false, regions: [READY, FAILED], error: null, busyId: null });
expect([...result.current.added]).toEqual(['oberbayern', 'niederbayern']);
expect(result.current.removing).toBeNull();
await settle(PADDLE_REGIONS_POLL_MS * 3);
expect(repo.list).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-PADDLEREGIONS-003: asks again on every poll while a region waits or is read, and stops once all are ready', async () => {
repo.list
.mockResolvedValueOnce([READY, QUEUED])
.mockResolvedValueOnce([READY, IMPORTING])
.mockResolvedValue([READY, DONE]);
const { result } = await mount();
expect(repo.list).toHaveBeenCalledTimes(1);
await settle(PADDLE_REGIONS_POLL_MS - 1);
expect(repo.list).toHaveBeenCalledTimes(1);
await settle(1);
expect(repo.list).toHaveBeenCalledTimes(2);
expect(result.current.regions).toEqual([READY, IMPORTING]);
await settle(PADDLE_REGIONS_POLL_MS);
expect(repo.list).toHaveBeenCalledTimes(3);
expect(result.current.regions).toEqual([READY, DONE]);
await settle(PADDLE_REGIONS_POLL_MS * 5);
expect(repo.list).toHaveBeenCalledTimes(3);
});
it('FE-HOOK-PADDLEREGIONS-004: a failed poll shows the error and keeps the regions; the next answer clears it', async () => {
repo.list
.mockResolvedValueOnce([QUEUED])
.mockRejectedValueOnce(apiError('Database is locked'))
.mockResolvedValue([DONE]);
const { result } = await mount();
await settle(PADDLE_REGIONS_POLL_MS);
expect(result.current).toMatchObject({ error: 'Database is locked', regions: [QUEUED], loading: false });
await settle(PADDLE_REGIONS_POLL_MS);
expect(result.current).toMatchObject({ error: null, regions: [DONE] });
await settle(PADDLE_REGIONS_POLL_MS * 3);
expect(repo.list).toHaveBeenCalledTimes(3);
});
it('FE-HOOK-PADDLEREGIONS-005: names why the list failed, offline or unknown, and a retry clears it', async () => {
repo.list
.mockRejectedValueOnce(new PaddleRegionsOfflineError())
.mockRejectedValueOnce('socket hang up')
.mockResolvedValue([READY]);
const { result } = await mount();
expect(result.current).toMatchObject({ error: OFFLINE, loading: false, regions: [] });
await act(() => result.current.retry());
expect(result.current.error).toBe('Error');
await act(() => result.current.retry());
expect(result.current).toMatchObject({ error: null, regions: [READY] });
});
it('FE-HOOK-PADDLEREGIONS-006: searches Geofabrik only from two characters and once the typing rests', async () => {
const { result } = await mount();
act(() => result.current.search.setQuery(' o '));
await settle(CATALOG_SEARCH_DEBOUNCE_MS * 3);
expect(repo.catalog).not.toHaveBeenCalled();
expect(result.current.search).toMatchObject({ query: ' o ', hits: null, searching: false, error: null });
act(() => result.current.search.setQuery(' ob'));
expect(result.current.search.searching).toBe(true);
await settle(CATALOG_SEARCH_DEBOUNCE_MS - 1);
act(() => result.current.search.setQuery(' obe'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS - 1);
expect(repo.catalog).not.toHaveBeenCalled();
await settle(1);
expect(repo.catalog).toHaveBeenCalledTimes(1);
expect(repo.catalog).toHaveBeenCalledWith('obe', expect.any(AbortSignal));
expect(result.current.search).toMatchObject({ query: ' obe', hits: [HIT], searching: false, error: null });
});
it('FE-HOOK-PADDLEREGIONS-007: the answer for the words typed last wins, an older one arriving late is dropped', async () => {
const first = deferred<PaddleRegionCatalogEntry[]>();
const second = deferred<PaddleRegionCatalogEntry[]>();
repo.catalog.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const { result } = await mount();
act(() => result.current.search.setQuery('ober'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
act(() => result.current.search.setQuery('oberpf'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
expect(repo.catalog.mock.calls.map(([q]) => q)).toEqual(['ober', 'oberpf']);
expect(repo.catalog.mock.calls[0][1]?.aborted).toBe(true);
first.resolve([OBERBAYERN_HIT, HIT]);
await settle();
expect(result.current.search).toMatchObject({ hits: null, searching: true });
second.resolve([HIT]);
await settle();
expect(result.current.search).toMatchObject({ query: 'oberpf', hits: [HIT], searching: false });
});
it('FE-HOOK-PADDLEREGIONS-008: says when Geofabrik cannot be reached, until the words are too short to search', async () => {
repo.catalog.mockRejectedValueOnce(apiError('Geofabrik did not answer', 'catalog_unavailable'));
const { result } = await mount();
act(() => result.current.search.setQuery('oberpfalz'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
expect(result.current.search).toMatchObject({
hits: null,
searching: false,
error: 'Geofabrik cannot be reached right now. Try again later.',
});
act(() => result.current.search.setQuery('o'));
expect(result.current.search.error).toBeNull();
});
it('FE-HOOK-PADDLEREGIONS-009: adding a region clears the search, says so and lists it', async () => {
repo.list.mockResolvedValueOnce([READY]).mockResolvedValue([READY, QUEUED]);
const { result } = await mount();
act(() => result.current.search.setQuery('oberpfalz'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
expect(result.current.added.has('oberpfalz')).toBe(false);
await act(() => result.current.add('oberpfalz'));
expect(repo.add).toHaveBeenCalledWith('oberpfalz');
expect(toast.success).toHaveBeenCalledWith('Region added. TREK reads it in the background.');
expect(toast.error).not.toHaveBeenCalled();
expect(result.current.search).toMatchObject({ query: '', hits: null, searching: false });
expect(repo.list).toHaveBeenCalledTimes(2);
expect(result.current.regions).toEqual([READY, QUEUED]);
expect(result.current.added.has('oberpfalz')).toBe(true);
expect(result.current.busyId).toBeNull();
});
it('FE-HOOK-PADDLEREGIONS-010: a refused add shows the server message and keeps the words typed', async () => {
repo.add.mockRejectedValueOnce(apiError('Geofabrik has no region oberpfalz-nord', 'unknown_region'));
const { result } = await mount();
act(() => result.current.search.setQuery('oberpfalz-nord'));
await act(() => result.current.add('oberpfalz-nord'));
expect(toast.error).toHaveBeenCalledWith('Geofabrik has no region oberpfalz-nord');
expect(toast.success).not.toHaveBeenCalled();
expect(result.current.search.query).toBe('oberpfalz-nord');
expect(repo.list).toHaveBeenCalledTimes(2);
expect(result.current.busyId).toBeNull();
});
it('FE-HOOK-PADDLEREGIONS-011: reading a region again asks quietly and reloads the list; a failure says why', async () => {
const { result } = await mount();
await act(() => result.current.refresh('oberbayern'));
expect(repo.refresh).toHaveBeenCalledWith('oberbayern');
expect(toast.success).not.toHaveBeenCalled();
expect(repo.list).toHaveBeenCalledTimes(2);
repo.refresh.mockRejectedValueOnce(new PaddleRegionsOfflineError());
await act(() => result.current.refresh('oberbayern'));
expect(toast.error).toHaveBeenCalledWith(OFFLINE);
expect(repo.list).toHaveBeenCalledTimes(3);
});
it('FE-HOOK-PADDLEREGIONS-012: removes a region only once the removal is confirmed', async () => {
const { result } = await mount();
act(() => result.current.askRemove(READY));
expect(result.current.removing).toEqual(READY);
act(() => result.current.cancelRemove());
expect(result.current.removing).toBeNull();
act(() => result.current.confirmRemove());
expect(repo.remove).not.toHaveBeenCalled();
expect(repo.list).toHaveBeenCalledTimes(1);
repo.list.mockResolvedValue([]);
act(() => result.current.askRemove(READY));
act(() => result.current.confirmRemove());
expect(repo.remove).toHaveBeenCalledWith('oberbayern');
expect(result.current).toMatchObject({ removing: null, busyId: 'oberbayern' });
await settle();
expect(toast.success).toHaveBeenCalledWith('Region removed');
expect(result.current).toMatchObject({ busyId: null, regions: [] });
expect(result.current.added.size).toBe(0);
});
it('FE-HOOK-PADDLEREGIONS-013: while one action runs, a second one is ignored', async () => {
const reading = deferred<PaddleRegion>();
repo.refresh.mockReturnValueOnce(reading.promise);
const { result } = await mount();
act(() => {
void result.current.refresh('oberbayern');
});
expect(result.current.busyId).toBe('oberbayern');
await act(() => result.current.add('oberpfalz'));
act(() => result.current.askRemove(READY));
act(() => result.current.confirmRemove());
expect(repo.add).not.toHaveBeenCalled();
expect(repo.remove).not.toHaveBeenCalled();
expect(repo.refresh).toHaveBeenCalledTimes(1);
expect(result.current.busyId).toBe('oberbayern');
reading.resolve(READY);
await settle();
expect(result.current.busyId).toBeNull();
expect(repo.list).toHaveBeenCalledTimes(2);
await act(() => result.current.add('oberpfalz'));
expect(repo.add).toHaveBeenCalledWith('oberpfalz');
});
it('FE-HOOK-PADDLEREGIONS-014: offline it asks nothing and shows no spinner, and picks up again with the connection', async () => {
repo.list.mockResolvedValue([QUEUED]);
setForcedOffline(true);
const { result } = await mount();
expect(result.current).toMatchObject({ offline: true, loading: false, regions: [], error: null });
act(() => result.current.search.setQuery('oberpfalz'));
await settle(CATALOG_SEARCH_DEBOUNCE_MS * 3);
expect(repo.list).not.toHaveBeenCalled();
expect(repo.catalog).not.toHaveBeenCalled();
expect(result.current.search).toMatchObject({ hits: null, searching: false, error: null });
act(() => setForcedOffline(false));
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
expect(result.current.offline).toBe(false);
expect(repo.list).toHaveBeenCalledTimes(1);
expect(repo.catalog).toHaveBeenCalledWith('oberpfalz', expect.any(AbortSignal));
expect(result.current.search.hits).toEqual([HIT]);
act(() => setForcedOffline(true));
await settle(PADDLE_REGIONS_POLL_MS * 4);
expect(repo.list).toHaveBeenCalledTimes(1);
expect(result.current.loading).toBe(false);
expect(result.current.search.hits).toBeNull();
act(() => setForcedOffline(false));
await settle();
expect(repo.list).toHaveBeenCalledTimes(2);
});
});
describe('paddleRegionsModel', () => {
it('FE-PADDLEREGIONSMODEL-001: waits on queued and importing reads, tones the badge by status and rounds the progress', () => {
const statuses: PaddleRegion['status'][] = ['queued', 'importing', 'ready', 'failed'];
expect(statuses.map((status) => isReading({ status }))).toEqual([true, true, false, false]);
expect(statuses.map((status) => regionStatusTone(status))).toEqual(['neutral', 'accent', 'success', 'danger']);
expect(progressPercent({ progress: null })).toBeNull();
expect(progressPercent({ progress: { phase: 'download', fraction: null } })).toBeNull();
expect(progressPercent({ progress: { phase: 'read', fraction: 0.456 } })).toBe(46);
expect(progressPercent({ progress: { phase: 'read', fraction: 1 } })).toBe(100);
});
});
@@ -0,0 +1,189 @@
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';
import { paddleRegionErrorCode, paddleRegionRepo } from '../../../repo/paddleRegionRepo';
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;
/** How long typing rests before Geofabrik's list is searched. */
export const CATALOG_SEARCH_DEBOUNCE_MS = import.meta.env.MODE === 'test' ? 10 : 350;
type Translate = ReturnType<typeof useTranslation>['t'];
function errorText(t: Translate, err: unknown): string {
const code = paddleRegionErrorCode(err);
if (code === 'offline') return t('admin.paddleRegions.offline');
if (code === 'catalog_unavailable') return t('admin.paddleRegions.catalogUnavailable');
return getApiErrorMessage(err, t('common.error'));
}
/**
* The regions as the server holds them, 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);
useEffect(() => {
if (!active || offline) return;
let cancelled = false;
paddleRegionRepo.list().then(
(list) => {
if (cancelled) return;
setRegions(list.regions);
setRequests(list.requests);
setError(null);
setLoaded(true);
},
(err: unknown) => {
if (cancelled) return;
setError(errorText(t, err));
setLoaded(true);
}
);
return () => {
cancelled = true;
};
}, [active, offline, t, asked]);
const reading = regions.some(isReading);
useEffect(() => {
if (!active || offline || !reading) return;
const timer = window.setInterval(() => setAsked((n) => n + 1), PADDLE_REGIONS_POLL_MS);
return () => window.clearInterval(timer);
}, [active, offline, reading]);
const reload = useCallback(() => setAsked((n) => n + 1), []);
return { regions, requests, loading: !loaded && !offline, error, reload };
}
/** Geofabrik's list searched as the admin types, the last search winning. */
function useCatalogSearch(offline: boolean, t: Translate) {
const [query, setQuery] = useState('');
const [result, setResult] = useState<{
needle: string;
hits: PaddleRegionCatalogEntry[] | null;
error: string | null;
} | null>(null);
const needle = query.trim();
const wanted = !offline && needle.length >= 2;
useEffect(() => {
if (!wanted) return;
const controller = new AbortController();
const timer = window.setTimeout(() => {
paddleRegionRepo
.catalog(needle, controller.signal)
.then((hits) => {
if (!controller.signal.aborted) setResult({ needle, hits, error: null });
})
.catch((err: unknown) => {
if (!controller.signal.aborted) setResult({ needle, hits: null, error: errorText(t, err) });
});
}, CATALOG_SEARCH_DEBOUNCE_MS);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [needle, wanted, t]);
// An answer counts only for the words it was asked for.
const current = wanted && result?.needle === needle ? result : null;
return { query, setQuery, hits: current?.hits ?? null, searching: wanted && !current, error: current?.error ?? null };
}
/**
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
* in Geofabrik's list to add one, reading a region again and removing it
* after a confirmation. Shown only while the Tours addon is on; the status of
* a read follows without reloading the page. 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();
const toast = useToast();
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);
const pending = useRef(false);
const { reload } = list;
const run = useCallback(
async (id: string, action: () => Promise<unknown>, done?: string) => {
if (pending.current) return;
pending.current = true;
setBusyId(id);
try {
await action();
if (done) toast.success(done);
} catch (err) {
toast.error(errorText(t, err));
} finally {
pending.current = false;
setBusyId(null);
reload();
}
},
[reload, t, toast]
);
const { setQuery } = search;
const add = useCallback(
(id: string) =>
run(
id,
async () => {
await paddleRegionRepo.add(id);
setQuery('');
},
t('admin.paddleRegions.toast.added')
),
[run, setQuery, t]
);
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
/** 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);
if (region) void run(region.id, () => paddleRegionRepo.remove(region.id), t('admin.paddleRegions.toast.removed'));
}, [removing, run, t]);
const added = useMemo(() => new Set(list.regions.map((region) => region.id)), [list.regions]);
return {
toursOn,
offline,
regions: list.regions,
requests: list.requests,
autoLoad,
loading: list.loading,
error: list.error,
retry: reload,
search,
added,
busyId,
add,
refresh,
approve,
decline,
removing,
askRemove: setRemoving,
cancelRemove: () => setRemoving(null),
confirmRemove,
};
}
@@ -24,7 +24,7 @@ export const CATEGORIES = [
{ id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
];
/**
@@ -0,0 +1,150 @@
// FE-STA2-PANE-001 to FE-STA2-PANE-006
// STA-02: the Atlas tab of the user's own tour activities: the distance large
// with how many tours at its corner, the climb, the types, what the numbers
// rest on, and the full list in a dialog.
import userEvent from '@testing-library/user-event';
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildSettings } from '../../../../tests/helpers/factories';
import { render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { AtlasTourActivityPane } from './AtlasTourActivityPane';
import type { TourActivityStats } from './useTourActivityStats';
const hook = vi.hoisted(() => ({ current: null as unknown, active: [] as boolean[] }));
vi.mock('./useTourActivityStats', () => ({
useTourActivityStats: (active: boolean) => {
hook.active.push(active);
return hook.current;
},
}));
const TODAY = '2026-10-20';
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
derived_from_place_id: null,
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 12,
ascent_m: 800,
descent_m: 790,
excluded: false,
...overrides,
};
}
function statsOf(data: TourActivityStatsResponse | null, overrides: Partial<TourActivityStats> = {}) {
const stats: TourActivityStats = {
status: data ? 'ready' : 'loading',
data,
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
period: 'all',
setPeriod: vi.fn(),
periods: ['all', 'thisYear', 'last12'],
type: 'all',
setType: vi.fn(),
types: ['all', 'hike', 'kayak'],
pending: new Set(),
setExcluded: vi.fn().mockResolvedValue(undefined),
retry: vi.fn(),
...overrides,
};
hook.current = stats;
return stats;
}
const data: TourActivityStatsResponse = {
entries: [
entry(),
entry({ place_id: 2, name: 'Summit', date: '2026-08-01', distance_km: 20.5, ascent_m: 1200, descent_m: 1200 }),
entry({
place_id: 3,
name: 'Lake',
tour_type: 'kayak',
basis: 'track',
track: { place_id: 9, name: 'Watch' },
distance_km: 8,
ascent_m: null,
descent_m: null,
}),
],
uncounted: { unassigned: 0, undated: 1 },
};
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
hook.active = [];
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
});
describe('AtlasTourActivityPane', () => {
it('FE-STA2-PANE-001: follows whether the tab is shown, silent before, loading until the read is there', () => {
statsOf(null, { status: 'idle' });
const { rerender } = render(<AtlasTourActivityPane active={false} />);
expect(hook.active).toEqual([false]);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
statsOf(null);
rerender(<AtlasTourActivityPane active />);
expect(hook.active).toEqual([false, true]);
expect(screen.getByRole('status')).toHaveTextContent('Loading');
});
it('FE-STA2-PANE-002: shows the distance large with the tour count at its corner, and the climb', () => {
statsOf(data);
render(<AtlasTourActivityPane active />);
expect(screen.getByTestId('atlas-tour-activity-distance')).toHaveTextContent('40.5km');
expect(screen.getByTestId('atlas-tour-activity-count')).toHaveTextContent('3');
expect(screen.getByTestId('atlas-tour-activity-ascent')).toHaveTextContent('2000m');
expect(screen.getByTestId('atlas-tour-activity-type-hike')).toHaveTextContent('2');
expect(screen.getByTestId('atlas-tour-activity-type-kayak')).toHaveTextContent('8 km');
});
it('FE-STA2-PANE-003: says what the numbers rest on and what does not count', () => {
statsOf(data);
render(<AtlasTourActivityPane active />);
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('Planned2');
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('Recorded1');
expect(screen.getByTestId('tour-activity-undated')).toHaveTextContent('1');
});
it('FE-STA2-PANE-004: one type chosen leaves the type columns out; nothing done says so', () => {
statsOf(data, { type: 'hike', summary: summarizeTourActivities(data.entries, { today: TODAY, type: 'hike' }) });
const { rerender } = render(<AtlasTourActivityPane active />);
expect(screen.queryByTestId('atlas-tour-activity-type-hike')).not.toBeInTheDocument();
statsOf(data, { summary: summarizeTourActivities(data.entries, { today: TODAY, from: '2027-01-01' }) });
rerender(<AtlasTourActivityPane active />);
expect(screen.getByText('No completed tours in this period')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Show tours' })).not.toBeInTheDocument();
});
it('FE-STA2-PANE-005: the full list opens in the dialog', async () => {
statsOf(data);
const user = userEvent.setup();
render(<AtlasTourActivityPane active />);
await user.click(screen.getByRole('button', { name: 'Show tours' }));
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-list')).toBeInTheDocument();
});
it('FE-STA2-PANE-006: offline and failed reads say so, a failed one with a retry', async () => {
statsOf(null, { status: 'offline' });
const { rerender } = render(<AtlasTourActivityPane active />);
expect(screen.getByText(/needs a connection/i)).toBeInTheDocument();
const failed = statsOf(null, { status: 'error' });
rerender(<AtlasTourActivityPane active />);
expect(screen.getByRole('alert')).toHaveTextContent('Tour activities could not be loaded');
await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' }));
expect(failed.retry).toHaveBeenCalled();
});
});
@@ -0,0 +1,223 @@
import type { TourActivitySummary } from '@trek/shared';
import { Footprints, ListOrdered } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, InfoDot } from '../../Tours/tourParts';
import { ActivityFilters, ActivityStatusLine, BasisBadges, UncountedBadges } from './tourActivityParts';
import { distanceReading, heightReading, typeIcon, typeLabel, useDistanceUnit } from './tourActivityReading';
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
import { splitMeasure } from './tourActivityStatsModel';
import { useTourActivityStats, type TourActivityStats } from './useTourActivityStats';
/** The wash the hero tile sits on, as the Atlas numbers do: the text colour, faintly, over the glass. */
const HERO_TINT = 'color-mix(in srgb, var(--text-primary) 7%, transparent)';
/** How many types the strip names before the dialog takes over. */
const STRIP_TYPES = 3;
const EYEBROW = 'mt-1.5 whitespace-nowrap font-semibold uppercase tracking-wide text-content-faint';
function Divider() {
return <span aria-hidden="true" className="mx-3 my-3 w-0.5 flex-none rounded-full bg-edge-faint" />;
}
/**
* The distance of the period, large, with how many tours at its corner; the
* tour count stands in when no tour knows its distance.
*/
function Hero({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
const { t } = useTranslation();
const reading = distanceReading(summary.totals, unit);
const shown = reading ? splitMeasure(reading.text) : { value: String(summary.totals.count), unit: '' };
const countLabel = `${t('tours.mode.tours')}: ${summary.totals.count}`;
return (
<div
className="relative mx-2 my-2 flex flex-col justify-center rounded-xl px-5 py-4"
style={{ background: HERO_TINT }}
>
<div className="flex items-baseline gap-1.5" data-testid="atlas-tour-activity-distance">
<span className="font-black tabular-nums leading-none text-content" style={fs(40, 'title')}>
{shown.value}
</span>
{shown.unit && (
<span className="font-medium text-content-muted" style={fs(14, 'body')}>
{shown.unit}
</span>
)}
</div>
<span className={EYEBROW} style={fs(9)}>
{reading ? t('tours.detail.distance') : t('tours.mode.tours')}
</span>
{reading && (
<Tooltip label={countLabel}>
<span
aria-label={countLabel}
data-testid="atlas-tour-activity-count"
className="absolute -end-2 -top-2 inline-flex h-[22px] items-center gap-1 rounded-full bg-surface-card px-2 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint"
style={fs(11)}
>
<Footprints size={11} strokeWidth={2.2} className="flex-none text-content-muted" />
{summary.totals.count}
</span>
</Tooltip>
)}
</div>
);
}
/** A height sum as the Atlas numbers stand: the value, its unit, and what it is below. */
function Figure({ label, text, testId }: { label: string; text: string; testId: string }) {
const { value, unit } = splitMeasure(text);
return (
<div className="flex flex-none flex-col items-center justify-center px-3 py-4" data-testid={testId}>
<span className="flex items-baseline gap-1">
<span className="font-black tabular-nums leading-none text-content" style={fs(24, 'subtitle')}>
{value}
</span>
<span className="font-medium text-content-muted" style={fs(11, 'body')}>
{unit}
</span>
</span>
<span className={EYEBROW} style={fs(9)}>
{label}
</span>
</div>
);
}
/** The types of the period, the most tours first: each an icon with its count at the corner, its distance below. */
function TypeColumns({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
const { t } = useTranslation();
return (
<div className="flex flex-none items-center gap-4 px-2 py-3">
{summary.types.slice(0, STRIP_TYPES).map((scope) => {
const Icon = typeIcon(scope.type);
const distance = distanceReading(scope, unit);
const label = `${typeLabel(scope.type, t)}: ${scope.count}${distance ? `, ${distance.text}` : ''}`;
return (
<Tooltip key={scope.type} label={label}>
<span
aria-label={label}
className="flex flex-col items-center gap-1.5"
data-testid={`atlas-tour-activity-type-${scope.type}`}
>
<span className="relative">
<span className="grid h-8 w-8 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint">
<Icon size={15} strokeWidth={1.9} />
</span>
<span
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
style={fs(9)}
>
{scope.count}
</span>
</span>
{distance && (
<span
className="whitespace-nowrap font-semibold tabular-nums text-content-secondary"
style={fs(11, 'body')}
>
{distance.text}
</span>
)}
</span>
</Tooltip>
);
})}
</div>
);
}
/** What the sums rest on, what does not count, and the way into the full list. */
interface StripProps {
stats: TourActivityStats;
summary: TourActivitySummary;
onOpen: () => void;
}
function SideColumn({ stats, summary, onOpen }: StripProps) {
const { t } = useTranslation();
const uncounted = stats.data?.uncounted ?? { unassigned: 0, undated: 0 };
return (
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
<div className="flex flex-wrap items-center gap-1.5">
<BasisBadges byBasis={summary.totals.byBasis} />
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
</div>
<div className="flex flex-wrap items-center gap-1.5">
<UncountedBadges upcoming={summary.upcoming} {...uncounted} />
</div>
{summary.done.length > 0 && (
<CardAction icon={<ListOrdered size={13} strokeWidth={2.2} />} onClick={onOpen}>
{t('tours.stats.showTours')}
</CardAction>
)}
</div>
);
}
/** The numbers of the period, or why there are none, with the filters before them. */
function Strip({ stats, summary, onOpen }: StripProps) {
const { t } = useTranslation();
const unit = useDistanceUnit();
const ascent = heightReading(summary.totals.ascentM, unit);
const descent = heightReading(summary.totals.descentM, unit);
const counted = summary.totals.count > 0;
return (
<>
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
<ActivityFilters stats={stats} quiet />
</div>
{counted ? (
<>
<Hero summary={summary} unit={unit} />
{ascent && <Figure label={t('tours.detail.ascent')} text={ascent.text} testId="atlas-tour-activity-ascent" />}
{descent && (
<Figure label={t('tours.detail.descent')} text={descent.text} testId="atlas-tour-activity-descent" />
)}
{stats.type === 'all' && summary.types.length > 1 && (
<>
<Divider />
<TypeColumns summary={summary} unit={unit} />
</>
)}
</>
) : (
<p className="m-0 flex max-w-[260px] items-center px-4 py-5 text-content-muted" style={fs(12, 'body')}>
{t(stats.data?.entries.length ? 'tours.stats.none' : 'tours.stats.empty')}
</p>
)}
<Divider />
<SideColumn stats={stats} summary={summary} onOpen={onOpen} />
</>
);
}
/**
* STA-02: the Atlas tab of the user's own tour activities: the period and type,
* the distance done with how many tours at its corner, the climb, the types,
* and what the numbers rest on (the trip planning, or recorded tracks where
* linked). The full list, where a tour can be left out, opens in a dialog. The
* read waits until the tab is first shown.
*/
export function AtlasTourActivityPane({ active, maxWidth }: { active: boolean; maxWidth?: number }) {
const stats = useTourActivityStats(active);
const [open, setOpen] = useState(false);
return (
<div
className="flex w-full items-stretch justify-center overflow-x-auto px-2"
style={{ maxWidth }}
data-testid="atlas-tour-activity"
>
{stats.summary ? (
<Strip stats={stats} summary={stats.summary} onOpen={() => setOpen(true)} />
) : (
<div className="flex items-center px-6 py-5">
<ActivityStatusLine stats={stats} />
</div>
)}
<TourActivityStatsDialog stats={stats} open={open} onClose={() => setOpen(false)} />
</div>
);
}
@@ -0,0 +1,159 @@
import type { TourActivityStatsEntry } from '@trek/shared';
import { Ban, Briefcase, CalendarDays, Footprints, Loader2, Repeat, RotateCcw } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../../i18n';
import { formatDate } from '../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../utils/units';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { EntryTile, MeasurePills } from './tourActivityParts';
import { useDistanceUnit } from './tourActivityReading';
const ROW_BADGE_ICON = 'flex-none text-content-faint';
/** What the entry's values rest on, as a pill: the plan, or the track that replaced it, named in the tooltip. */
function BasisPill({ entry }: { entry: TourActivityStatsEntry }) {
const { t } = useTranslation();
const track = entry.basis === 'track';
const hint = track
? t('tours.stats.basisTrackHint', { name: entry.track?.name ?? '' })
: t('tours.stats.basisPlanHint');
return (
<Tooltip label={hint}>
<span
className="inline-flex"
aria-label={hint}
data-testid={`tour-activity-basis-${entry.basis}-${entry.place_id}`}
>
<SoftPill
tone={track ? 'success' : 'neutral'}
icon={
track ? (
<Footprints size={10} strokeWidth={2.2} className="flex-none" />
) : (
<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />
)
}
>
{t(track ? 'tours.stats.basisTrack' : 'tours.stats.basisPlan')}
</SoftPill>
</span>
</Tooltip>
);
}
/** The day it counts on, the trip it is in (a way into that trip), and how many days the plan has it on. */
function EntryFacts({ entry }: { entry: TourActivityStatsEntry }) {
const { t, locale } = useTranslation();
const navigate = useNavigate();
return (
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
<WhiteBadge icon={<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
{formatDate(entry.date, locale) ?? entry.date}
</WhiteBadge>
<Tooltip label={t('tours.stats.openTrip')}>
<button
type="button"
onClick={() => void navigate(`/trips/${entry.trip.id}`)}
className="inline-flex min-w-0 max-w-[220px] rounded-full transition-opacity hover:opacity-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<WhiteBadge
className="min-w-0 max-w-full"
icon={<Briefcase size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}
>
<span className="truncate">{entry.trip.title}</span>
</WhiteBadge>
</button>
</Tooltip>
{entry.day_count > 1 && (
<Tooltip label={t('tours.stats.multiDayHint')}>
<span className="inline-flex" aria-label={t('tours.stats.multiDayHint')}>
<WhiteBadge icon={<Repeat size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
{entry.day_count}
</WhiteBadge>
</span>
</Tooltip>
)}
<BasisPill entry={entry} />
{entry.excluded && <SoftPill caps>{t('tours.stats.excluded')}</SoftPill>}
</span>
);
}
/** The round button that leaves the tour out, or counts it again; named by its tooltip. */
function ExcludeToggle({
entry,
busy,
onToggle,
}: {
entry: TourActivityStatsEntry;
busy: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const label = entry.excluded ? t('tours.stats.include') : t('tours.stats.exclude');
const hint = entry.excluded ? label : `${label}: ${t('tours.stats.excludeHint')}`;
let icon = <Ban size={14} strokeWidth={2} />;
if (busy) icon = <Loader2 size={14} strokeWidth={2} className="animate-spin" />;
else if (entry.excluded) icon = <RotateCcw size={14} strokeWidth={2} />;
return (
<Tooltip label={hint}>
<button
type="button"
onClick={onToggle}
disabled={busy}
aria-label={label}
aria-pressed={entry.excluded}
data-testid={`tour-activity-toggle-${entry.place_id}`}
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50"
>
{icon}
</button>
</Tooltip>
);
}
/**
* STA-02: one tour of the user's activities: its type, name and where it
* counts, its distance and climb, and the switch to leave it out. A tour left
* out stays in the list, dimmed, so it can be counted again in place.
*/
export function TourActivityEntryRow({
entry,
busy,
onToggleExcluded,
}: {
entry: TourActivityStatsEntry;
busy: boolean;
onToggleExcluded: (entry: TourActivityStatsEntry) => void;
}) {
const unit = useDistanceUnit();
const key = entry.track ? `${entry.place_id}-${entry.track.place_id}` : String(entry.place_id);
return (
<li
data-testid={`tour-activity-row-${key}`}
className="flex min-w-0 items-center gap-3 rounded-[12px] px-2.5 py-2 transition-colors hover:bg-surface-hover"
>
<span
className={`flex min-w-0 flex-1 items-center gap-3 transition-opacity ${entry.excluded ? 'opacity-50' : ''}`}
>
<EntryTile entry={entry} />
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>
{entry.name}
</span>
<EntryFacts entry={entry} />
</span>
<span className="flex flex-none flex-col items-end gap-1">
<MeasurePills
distance={entry.distance_km === null ? null : formatDistance(entry.distance_km, unit)}
ascent={entry.ascent_m === null ? null : formatElevation(entry.ascent_m, unit)}
testId={`tour-activity-${key}`}
/>
</span>
</span>
<ExcludeToggle entry={entry} busy={busy} onToggle={() => onToggleExcluded(entry)} />
</li>
);
}
@@ -0,0 +1,177 @@
// FE-STA2-DIALOG-001 to FE-STA2-DIALOG-008
// STA-02: the full list of the user's tour activities: the sums with what they
// rest on, the split by type, each tour with its basis and a switch to leave it
// out (dimmed, never hidden), and the way into its trip.
import userEvent from '@testing-library/user-event';
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildSettings } from '../../../../tests/helpers/factories';
import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
import type { TourActivityStats } from './useTourActivityStats';
const navigate = vi.hoisted(() => vi.fn());
vi.mock('react-router', async (importOriginal) => ({
...(await importOriginal<typeof import('react-router')>()),
useNavigate: () => navigate,
}));
const TODAY = '2026-10-20';
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
derived_from_place_id: null,
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 12,
ascent_m: 800,
descent_m: 790,
excluded: false,
...overrides,
};
}
function statsOf(
data: TourActivityStatsResponse | null,
overrides: Partial<TourActivityStats> = {}
): TourActivityStats {
return {
status: data ? 'ready' : 'loading',
data,
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
period: 'all',
setPeriod: vi.fn(),
periods: ['all', 'thisYear', 'last12'],
type: 'all',
setType: vi.fn(),
types: ['all', 'hike', 'kayak'],
pending: new Set(),
setExcluded: vi.fn().mockResolvedValue(undefined),
retry: vi.fn(),
...overrides,
};
}
const data: TourActivityStatsResponse = {
entries: [
entry(),
entry({
place_id: 2,
name: 'Lake',
tour_type: 'kayak',
date: '2026-08-01',
basis: 'track',
track: { place_id: 9, name: 'Watch export' },
distance_km: 8.5,
ascent_m: null,
descent_m: null,
}),
entry({ place_id: 3, name: 'Long trail', date: '2026-06-01', day_count: 3, distance_km: null }),
entry({ place_id: 4, name: 'Rained off', date: '2026-05-01', excluded: true }),
entry({ place_id: 5, name: 'Next week', date: '2026-10-27' }),
],
uncounted: { unassigned: 2, undated: 0 },
};
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
});
describe('TourActivityStatsDialog', () => {
it('FE-STA2-DIALOG-001: shows the sums, at least where a tour lacks the distance, and what they rest on', () => {
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
expect(screen.getByText('≥ 20.5 km')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('2');
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('1');
expect(screen.getByTestId('tour-activity-upcoming')).toHaveTextContent('1');
expect(screen.getByTestId('tour-activity-unassigned')).toHaveTextContent('2');
expect(screen.queryByTestId('tour-activity-undated')).not.toBeInTheDocument();
});
it('FE-STA2-DIALOG-002: splits by type, the count at the corner of each type', () => {
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
expect(within(screen.getByTestId('tour-activity-type-hike')).getByText('2')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-type-kayak-distance')).toHaveTextContent('8.5 km');
expect(screen.queryByTestId('tour-activity-type-kayak-ascent')).not.toBeInTheDocument();
});
it('FE-STA2-DIALOG-003: lists every done tour, newest first, with its basis; the one ahead is not there', () => {
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
const list = screen.getByTestId('tour-activity-list');
expect(within(list).getAllByRole('listitem')).toHaveLength(4);
expect(screen.queryByText('Next week')).not.toBeInTheDocument();
expect(screen.getByTestId('tour-activity-row-2-9')).toHaveTextContent('Recorded');
expect(screen.getByTestId('tour-activity-track-corner')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-row-1')).toHaveTextContent('Planned');
expect(
within(screen.getByTestId('tour-activity-row-3')).getByLabelText('Planned on several days, counted once')
).toHaveTextContent('3');
expect(screen.queryByTestId('tour-activity-3-distance')).not.toBeInTheDocument();
});
it('FE-STA2-DIALOG-004: a tour left out stays listed and can be counted again, a counted one left out', async () => {
const stats = statsOf(data);
const user = userEvent.setup();
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
expect(screen.getByTestId('tour-activity-row-4')).toHaveTextContent('Left out');
await user.click(screen.getByRole('button', { name: 'Count again' }));
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4 }), false);
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByRole('button', { name: 'Leave out' }));
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 1 }), true);
});
it('FE-STA2-DIALOG-005: a tour on its way to the server cannot be switched twice', () => {
render(<TourActivityStatsDialog stats={statsOf(data, { pending: new Set([1]) })} open onClose={vi.fn()} />);
expect(screen.getByTestId('tour-activity-toggle-1')).toBeDisabled();
});
it('FE-STA2-DIALOG-006: the trip of a tour opens from its badge', async () => {
const user = userEvent.setup();
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByText('Alps'));
expect(navigate).toHaveBeenCalledWith('/trips/5');
});
it('FE-STA2-DIALOG-007: the filters sit on the head band and change period and type', async () => {
const stats = statsOf(data);
const user = userEvent.setup();
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /Period/ }));
await user.click(screen.getByRole('button', { name: 'This year' }));
expect(stats.setPeriod).toHaveBeenCalledWith('thisYear');
await user.click(screen.getByRole('button', { name: /Type/ }));
await user.click(screen.getByRole('button', { name: 'Paddle tour' }));
expect(stats.setType).toHaveBeenCalledWith('kayak');
});
it('FE-STA2-DIALOG-008: before the read, while empty, and closed', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
const { rerender } = render(<TourActivityStatsDialog stats={statsOf(null)} open onClose={onClose} />);
expect(screen.getByRole('status')).toHaveTextContent('Loading');
rerender(
<TourActivityStatsDialog
stats={statsOf({ entries: [], uncounted: { unassigned: 0, undated: 0 } })}
open
onClose={onClose}
/>
);
expect(screen.getByText('No completed tours yet')).toBeInTheDocument();
const [closeButton] = screen.getAllByRole('button', { name: 'Close' });
await user.click(closeButton as HTMLElement);
expect(onClose).toHaveBeenCalled();
rerender(<TourActivityStatsDialog stats={statsOf(null)} open={false} onClose={onClose} />);
expect(screen.queryByText('My tour activities')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,181 @@
import type { TourActivityTotals, TourTypeKey } from '@trek/shared';
import { Footprints } from 'lucide-react';
import { createElement, useId } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { InfoDot, TourFactFields, type TourMetricField } from '../../Tours/tourParts';
import { TourActivityEntryRow } from './TourActivityEntryRow';
import { ActivityFilters, ActivityStatusLine, BasisBadges, MeasurePills, UncountedBadges } from './tourActivityParts';
import { distanceReading, heightReading, partialHint, typeIcon, useDistanceUnit } from './tourActivityReading';
import type { TourActivityStats } from './useTourActivityStats';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The sums as labelled fields; a sum nobody knows is left out, a short one says at least and why. */
function totalFields(totals: TourActivityTotals, unit: DistanceUnit, t: Translate): TourMetricField[] {
const fields: TourMetricField[] = [{ label: t('tours.mode.tours'), value: String(totals.count) }];
const sums = [
{ label: t('tours.detail.distance'), sum: totals.distanceKm, reading: distanceReading(totals, unit) },
{ label: t('tours.detail.ascent'), sum: totals.ascentM, reading: heightReading(totals.ascentM, unit) },
{ label: t('tours.detail.descent'), sum: totals.descentM, reading: heightReading(totals.descentM, unit) },
];
for (const { label, sum, reading } of sums) {
if (!reading) continue;
const info = reading.partial ? { label, description: partialHint(sum, t) } : undefined;
fields.push({ label, value: reading.text, info });
}
return fields;
}
/** One type of the period: its icon with how many tours at the corner, its name, its distance and climb. */
function TypeLine({ type, totals }: { type: TourTypeKey; totals: TourActivityTotals }) {
const { t } = useTranslation();
const unit = useDistanceUnit();
const distance = distanceReading(totals, unit);
const ascent = heightReading(totals.ascentM, unit);
return (
<li className="flex min-w-0 items-center gap-3 py-1" data-testid={`tour-activity-type-${type}`}>
<span className="relative flex-none">
<span className="grid h-[30px] w-[30px] place-items-center rounded-full bg-edge text-content-secondary">
{createElement(typeIcon(type), { size: 14, strokeWidth: 1.9 })}
</span>
<span
aria-hidden="true"
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-surface-card px-1 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-edge-faint"
style={fs(9)}
>
{totals.count}
</span>
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
{t(`tourTypes.${type}`)}
<span className="sr-only">{`: ${t('tours.mode.tours')} ${totals.count}`}</span>
</span>
<span className="flex flex-none items-center gap-1.5">
<MeasurePills
distance={distance?.text ?? null}
ascent={ascent?.text ?? null}
testId={`tour-activity-type-${type}`}
/>
</span>
</li>
);
}
/** The sums, what they rest on, the split by type, and every tour of the period with its switch. */
function StatsBody({ stats }: { stats: TourActivityStats }) {
const { t } = useTranslation();
const unit = useDistanceUnit();
const { summary, data } = stats;
if (!summary || !data) return <ActivityStatusLine stats={stats} />;
const showTypes = stats.type === 'all' && summary.types.length > 1;
return (
<>
<div className="flex flex-col gap-2.5">
<TourFactFields fields={totalFields(summary.totals, unit, t)} />
<div className="flex flex-wrap items-center gap-1.5">
<BasisBadges byBasis={summary.totals.byBasis} />
<UncountedBadges upcoming={summary.upcoming} {...data.uncounted} />
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
</div>
</div>
{showTypes && (
<DialogSection label={t('tours.stats.byType')}>
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{summary.types.map(({ type, ...totals }) => (
<TypeLine key={type} type={type} totals={totals} />
))}
</ul>
</DialogSection>
)}
<DialogSection
label={t('tours.mode.tours')}
action={
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
{summary.done.length}
</span>
}
>
{summary.done.length === 0 ? (
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
{t(data.entries.length === 0 ? 'tours.stats.empty' : 'tours.stats.none')}
</p>
) : (
<ul className="-mx-2.5 my-0 flex list-none flex-col gap-0.5 p-0" data-testid="tour-activity-list">
{summary.done.map((entry) => (
<TourActivityEntryRow
key={entry.track ? `${entry.place_id}-${entry.track.place_id}` : entry.place_id}
entry={entry}
busy={stats.pending.has(entry.place_id)}
onToggleExcluded={(target) => void stats.setExcluded(target, !target.excluded)}
/>
))}
</ul>
)}
</DialogSection>
</>
);
}
/**
* STA-02: the user's tour activities in full, opened from the Atlas: the
* period and type on the head band, the sums with what they rest on, the
* split by type, and every tour done in the period, each one able to be left
* out (a tour that fell through) and counted again without touching its plan.
*/
export function TourActivityStatsDialog({
stats,
open,
onClose,
}: {
stats: TourActivityStats;
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const labelId = useId();
return (
<DialogShell
open={open}
onClose={onClose}
labelledBy={labelId}
width="editor"
align="top"
header={
<DialogHeader
tile={
<DialogTile>
<Footprints size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('tours.stats.title')}
sub={t('tours.stats.sub')}
subWraps
pills={<ActivityFilters stats={stats} />}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.close')}</DialogButton>
</DialogFooter>
}
>
<StatsBody stats={stats} />
</DialogShell>
);
}
@@ -0,0 +1,232 @@
import type { TourActivityBasis, TourActivityStatsEntry } from '@trek/shared';
import {
AlertTriangle,
ArrowUp,
CalendarClock,
CalendarDays,
CalendarRange,
Footprints,
Loader2,
Ruler,
WifiOff,
} from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../../i18n';
import { PillSelect } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { FigureBadge } from '../../Tours/paddle/FigureBadge';
import { TourDayRowPill } from '../../Tours/TourDayRowFacts';
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
import { typeIcon, typeLabel, type Translate } from './tourActivityReading';
import { periodYear, type ActivityPeriod, type ActivityType } from './tourActivityStatsModel';
import type { TourActivityStats } from './useTourActivityStats';
/** What a period is called in the filter. */
function periodLabel(period: ActivityPeriod, t: Translate): string {
if (period === 'all') return t('tours.stats.period.all');
if (period === 'thisYear') return t('tours.stats.period.thisYear');
if (period === 'last12') return t('tours.stats.period.last12');
return String(periodYear(period));
}
/** The period and type filters, as head band pills or quiet ones in a panel. */
export function ActivityFilters({ stats, quiet = false }: { stats: TourActivityStats; quiet?: boolean }) {
const { t } = useTranslation();
const typeOption = (type: ActivityType) => ({
value: type,
label: typeLabel(type, t),
icon: createElement(typeIcon(type), { size: 12, strokeWidth: 2.2, className: 'flex-none text-content-muted' }),
});
return (
<>
<PillSelect
quiet={quiet}
label={t('tours.stats.period')}
value={stats.period}
options={stats.periods.map((period) => ({
value: period,
label: periodLabel(period, t),
icon: <CalendarRange size={12} strokeWidth={2.2} className="flex-none text-content-muted" />,
}))}
fallback={{ label: periodLabel(stats.period, t) }}
onChange={stats.setPeriod}
/>
<PillSelect
quiet={quiet}
label={t('tours.filter.type')}
value={stats.type}
options={stats.types.map(typeOption)}
fallback={typeOption(stats.type)}
onChange={stats.setType}
/>
</>
);
}
/** A distance and a climb as white badges, each named in full by its tooltip; one not known is left out. */
export function MeasurePills({
distance,
ascent,
testId,
}: {
distance: string | null;
ascent: string | null;
/** Prefix of the badges' test ids: `<testId>-distance`, `<testId>-ascent`. */
testId: string;
}) {
const { t } = useTranslation();
return (
<>
{distance !== null && (
<TourDayRowPill
icon={Ruler}
value={distance}
label={`${t('tours.detail.distance')}: ${distance}`}
testId={`${testId}-distance`}
/>
)}
{ascent !== null && (
<TourDayRowPill
icon={ArrowUp}
value={ascent}
label={`${t('tours.detail.ascent')}: ${ascent}`}
testId={`${testId}-ascent`}
/>
)}
</>
);
}
/**
* What the sums rest on, each as a figure badge: how many tours count with
* their planned values and how many with a recorded track. The planned values
* are never passed off as measured, so they always say so.
*/
export function BasisBadges({ byBasis }: { byBasis: Record<TourActivityBasis, number> }) {
const { t } = useTranslation();
return (
<>
{byBasis.plan > 0 && (
<FigureBadge
testId="tour-activity-basis-plan"
label={t('tours.stats.basisPlan')}
value={String(byBasis.plan)}
icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
info={t('tours.stats.basisPlanHint')}
/>
)}
{byBasis.track > 0 && (
<FigureBadge
testId="tour-activity-basis-track"
label={t('tours.stats.basisTrack')}
value={String(byBasis.track)}
icon={<Footprints size={10} strokeWidth={2.2} className="flex-none text-success" />}
info={t('tours.stats.basisTrackCountHint')}
/>
)}
</>
);
}
/** What does not count, said as quiet figure badges: tours still ahead, on no day, on days without a date. */
export function UncountedBadges({
upcoming,
unassigned,
undated,
}: {
upcoming: number;
unassigned: number;
undated: number;
}) {
const { t } = useTranslation();
const badges = [
{ key: 'upcoming', value: upcoming, labelKey: 'tours.stats.upcoming', infoKey: 'tours.stats.upcomingHint' },
{
key: 'unassigned',
value: unassigned,
labelKey: 'tours.stats.unassigned',
infoKey: 'tours.stats.unassignedHint',
},
{ key: 'undated', value: undated, labelKey: 'tours.stats.undated', infoKey: 'tours.stats.undatedHint' },
];
return (
<>
{badges
.filter((badge) => badge.value > 0)
.map((badge) => (
<FigureBadge
key={badge.key}
testId={`tour-activity-${badge.key}`}
label={t(badge.labelKey)}
value={String(badge.value)}
icon={<CalendarClock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
info={t(badge.infoKey)}
/>
))}
</>
);
}
/**
* A tour's type as a round tile; a tour done with a recorded track carries
* the footprints at its corner, the way a waypoint's number carries its kind.
*/
export function EntryTile({ entry }: { entry: Pick<TourActivityStatsEntry, 'tour_type' | 'basis'> }) {
const { t } = useTranslation();
return (
<Tooltip label={t(`tourTypes.${entry.tour_type}`)}>
<span className="relative flex-none" aria-label={t(`tourTypes.${entry.tour_type}`)}>
<span className="grid h-[34px] w-[34px] place-items-center rounded-full bg-edge text-content-secondary">
{createElement(tourDayRowIcon(entry.tour_type), { size: 15, strokeWidth: 1.9 })}
</span>
{entry.basis === 'track' && (
<span
aria-hidden="true"
data-testid="tour-activity-track-corner"
className="absolute -bottom-1 -end-1 grid h-4 w-4 place-items-center rounded-full bg-success-soft text-success ring-2 ring-surface-card"
>
<Footprints size={9} strokeWidth={2.6} />
</span>
)}
</span>
</Tooltip>
);
}
/**
* Where the read stands while there is nothing to show yet: on its way, refused
* offline, or failed with a way to try again.
*/
export function ActivityStatusLine({ stats }: { stats: Pick<TourActivityStats, 'status' | 'retry'> }) {
const { t } = useTranslation();
// Not asked yet: the tab has not been shown, so there is nothing to say.
if (stats.status === 'idle') return null;
if (stats.status === 'offline')
return (
<p className="m-0 flex items-center gap-2 text-content-muted" style={fs(12, 'body')}>
<WifiOff size={13} strokeWidth={2} className="flex-none" />
{t('common.needsConnection')}
</p>
);
if (stats.status === 'error')
return (
<p role="alert" className="m-0 flex flex-wrap items-center gap-2 text-content-muted" style={fs(12, 'body')}>
<AlertTriangle size={13} strokeWidth={2} className="flex-none text-warning" />
{t('tours.stats.unavailable')}
<button
type="button"
onClick={stats.retry}
className="font-semibold text-content underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
{t('common.errorRetry')}
</button>
</p>
);
return (
<p role="status" className="m-0 flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
<Loader2 size={13} strokeWidth={2.2} className="flex-none animate-spin" />
{t('common.loading')}
</p>
);
}
@@ -0,0 +1,39 @@
import type { TourActivityTotals } from '@trek/shared';
import { Layers, type LucideIcon } from 'lucide-react';
import { useSettingsStore } from '../../../store/settingsStore';
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
import { sumReading, type ActivityType } from './tourActivityStatsModel';
export type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The distance unit the user reads in. */
export function useDistanceUnit(): DistanceUnit {
return useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
}
/** What a type is called in the filter. */
export function typeLabel(type: ActivityType, t: Translate): string {
return type === 'all' ? t('tours.stats.allTypes') : t(`tourTypes.${type}`);
}
/** The icon a type is drawn with; every type at once is a stack. */
export function typeIcon(type: ActivityType): LucideIcon {
return type === 'all' ? Layers : tourDayRowIcon(type);
}
/** A distance as said, at least that much when some tours do not know theirs; null when none does. */
export function distanceReading(totals: Pick<TourActivityTotals, 'distanceKm'>, unit: DistanceUnit) {
return sumReading(totals.distanceKm, (km) => formatDistance(km, unit));
}
/** A height sum as said, the same way. */
export function heightReading(sum: TourActivityTotals['ascentM'], unit: DistanceUnit) {
return sumReading(sum, (metres) => formatElevation(metres, unit));
}
/** How a short sum explains itself: known for some of the tours only. */
export function partialHint(sum: TourActivityTotals['distanceKm'], t: Translate): string {
return t('tours.scope.partialBody', { known: sum.known, of: sum.of });
}
@@ -0,0 +1,97 @@
// FE-STA2-MODEL-001 to FE-STA2-MODEL-007
// STA-02: the periods and types the Atlas offers for the user's tour
// activities, and how a sum reads when some tours do not know the value.
import type { TourActivityStatsEntry } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
periodOptions,
periodRange,
periodYear,
splitMeasure,
sumReading,
typeOptions,
} from './tourActivityStatsModel';
const TODAY = '2026-10-20';
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
derived_from_place_id: null,
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 10,
ascent_m: 500,
descent_m: 400,
excluded: false,
...overrides,
};
}
describe('tourActivityStatsModel', () => {
it('FE-STA2-MODEL-001: each period covers its days, both ends included', () => {
expect(periodRange('all', TODAY)).toEqual({ from: null, to: null });
expect(periodRange('thisYear', TODAY)).toEqual({ from: '2026-01-01', to: '2026-12-31' });
expect(periodRange('last12', TODAY)).toEqual({ from: '2025-10-20', to: TODAY });
expect(periodRange('year:2024', TODAY)).toEqual({ from: '2024-01-01', to: '2024-12-31' });
});
it('FE-STA2-MODEL-002: offers the earlier years a done tour is in, the newest first, and no empty one', () => {
const entries = [
entry({ date: '2023-05-01' }),
entry({ date: '2025-08-01' }),
entry({ date: '2025-09-01' }),
entry({ date: '2026-03-01' }),
];
expect(periodOptions(entries, TODAY)).toEqual(['all', 'thisYear', 'last12', 'year:2025', 'year:2023']);
expect(periodOptions([], TODAY)).toEqual(['all', 'thisYear', 'last12']);
});
it('FE-STA2-MODEL-003: offers the types of done tours only, in planning order, older types after them', () => {
const entries = [
entry({ tour_type: 'kayak' }),
entry({ tour_type: 'run' }),
entry({ tour_type: 'hike' }),
entry({ tour_type: 'bike', date: '2026-12-01' }),
];
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'run', 'kayak']);
});
it('FE-STA2-MODEL-004: reads the year of a year period only', () => {
expect(periodYear('year:2024')).toBe(2024);
expect(periodYear('thisYear')).toBeNull();
});
it('FE-STA2-MODEL-005: a sum nobody knows says nothing, a short one says at least', () => {
const km = (value: number) => `${value} km`;
expect(sumReading({ value: null, known: 0, of: 2 }, km)).toBeNull();
expect(sumReading({ value: 12, known: 2, of: 2 }, km)).toEqual({ text: '12 km', partial: false });
expect(sumReading({ value: 12, known: 1, of: 2 }, km)).toEqual({ text: '≥ 12 km', partial: true });
});
it('FE-STA2-MODEL-006: splits a measure into its number and unit', () => {
expect(splitMeasure('128.4 km')).toEqual({ value: '128.4', unit: 'km' });
expect(splitMeasure('≥ 12 km')).toEqual({ value: '≥ 12', unit: 'km' });
expect(splitMeasure('12')).toEqual({ value: '12', unit: '' });
});
it('FE-STA2-MODEL-007: a tour still ahead offers neither its year nor its type', () => {
expect(periodOptions([entry({ date: '2025-01-01' }), entry({ date: '2027-01-01' })], TODAY)).toEqual([
'all',
'thisYear',
'last12',
'year:2025',
]);
expect(typeOptions([entry({ tour_type: 'walk', date: TODAY })], TODAY)).toEqual(['all']);
});
it('FE-STA2-MODEL-008: a planned ski tour is a type of its own, after the types planned before it', () => {
const entries = [entry({ tour_type: 'ski' }), entry({ tour_type: 'horse' }), entry({ tour_type: 'hike' })];
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'horse', 'ski']);
});
});
@@ -0,0 +1,90 @@
import {
isTourActivityDone,
plannableTourTypeSchema,
type TourActivityStatsEntry,
type TourActivitySum,
type TourTypeKey,
} from '@trek/shared';
/**
* STA-02: the periods and types the Atlas narrows the user's tour activities
* by, and how a sum reads. Pure, so the hook and its views share one answer.
*/
/** All time, the calendar year, the last twelve months, or one earlier year. */
export type ActivityPeriod = 'all' | 'thisYear' | 'last12' | `year:${number}`;
/** One type, or every type at once. */
export type ActivityType = TourTypeKey | 'all';
interface PeriodRange {
from: string | null;
to: string | null;
}
/** The days a period covers, both included, for `today` (YYYY-MM-DD in the user's own calendar). */
export function periodRange(period: ActivityPeriod, today: string): PeriodRange {
const year = Number(today.slice(0, 4));
if (period === 'thisYear') return { from: `${year}-01-01`, to: `${year}-12-31` };
// A year back to the day, today included: the same day a year ago is the first one.
if (period === 'last12') return { from: `${year - 1}${today.slice(4)}`, to: today };
if (period.startsWith('year:')) {
const chosen = period.slice(5);
return { from: `${chosen}-01-01`, to: `${chosen}-12-31` };
}
return { from: null, to: null };
}
/**
* The periods worth offering: all time, this year and the last twelve months,
* then each earlier year a done tour is in, the newest first. A year without
* a tour would only ever show nothing.
*/
export function periodOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityPeriod[] {
const thisYear = today.slice(0, 4);
const years = new Set(
entries
.filter((entry) => isTourActivityDone(entry, today))
.map((entry) => entry.date.slice(0, 4))
.filter((year) => year < thisYear)
);
const earlier = [...years].sort((a, b) => b.localeCompare(a)).map((year): ActivityPeriod => `year:${Number(year)}`);
return ['all', 'thisYear', 'last12', ...earlier];
}
/** The types a done tour has, in the order tours are planned in, the older types after them. */
export function typeOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityType[] {
const present = new Set(entries.filter((entry) => isTourActivityDone(entry, today)).map((entry) => entry.tour_type));
const planned: readonly string[] = plannableTourTypeSchema.options;
const ordered = [...present].sort((a, b) => rank(planned, a) - rank(planned, b) || a.localeCompare(b));
return ['all', ...ordered];
}
function rank(order: readonly string[], value: string): number {
const index = order.indexOf(value);
return index === -1 ? order.length : index;
}
/** The year of a `year:` period, or null for the others. */
export function periodYear(period: ActivityPeriod): number | null {
return period.startsWith('year:') ? Number(period.slice(5)) : null;
}
/**
* A sum as it is said: nothing when nobody knows the value, else the value,
* marked as at least that much when some of the tours lack it.
*/
export function sumReading(
sum: TourActivitySum,
format: (value: number) => string
): { text: string; partial: boolean } | null {
if (sum.value === null) return null;
const partial = sum.known < sum.of;
return { text: partial ? `≥ ${format(sum.value)}` : format(sum.value), partial };
}
/** A formatted measure split into its number and its unit, so the number can stand large ("128.4", "km"). */
export function splitMeasure(text: string): { value: string; unit: string } {
const at = text.lastIndexOf(' ');
return at === -1 ? { value: text, unit: '' } : { value: text.slice(0, at), unit: text.slice(at + 1) };
}
@@ -0,0 +1,226 @@
// FE-STA2-HOOK-001 to FE-STA2-HOOK-009
// STA-02: the user's own tour activities are read when the tab is first shown,
// narrowed by period and type, and a tour left out shows at once and comes back
// with a message when the server refuses.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourActivityStatsEntry, TourActivityStatsResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTourActivityStats } from './useTourActivityStats';
const repo = vi.hoisted(() => ({ get: vi.fn(), setExcluded: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../../repo/tourActivityStatsRepo', () => ({
tourActivityStatsRepo: repo,
TourActivityStatsOfflineError: class TourActivityStatsOfflineError extends Error {},
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
derived_from_place_id: null,
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 10,
ascent_m: 500,
descent_m: 400,
excluded: false,
...overrides,
};
}
const stats: TourActivityStatsResponse = {
entries: [
entry(),
entry({ place_id: 2, name: 'Lake', tour_type: 'kayak', date: '2025-08-01', distance_km: 8 }),
entry({ place_id: 3, name: 'Summit', date: '2026-11-01' }),
],
uncounted: { unassigned: 0, undated: 0 },
};
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 9, 20, 12));
repo.get.mockResolvedValue(stats);
});
afterEach(() => vi.useRealTimers());
describe('useTourActivityStats', () => {
it('FE-STA2-HOOK-001: reads nothing until the tab is shown, then sums what is done', async () => {
const { result, rerender } = renderHook(({ active }) => useTourActivityStats(active), {
initialProps: { active: false },
});
expect(result.current.status).toBe('idle');
expect(repo.get).not.toHaveBeenCalled();
rerender({ active: true });
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.summary?.totals.count).toBe(2);
expect(result.current.summary?.totals.distanceKm.value).toBe(18);
expect(result.current.summary?.upcoming).toBe(1);
expect(result.current.periods).toEqual(['all', 'thisYear', 'last12', 'year:2025']);
expect(result.current.types).toEqual(['all', 'hike', 'kayak']);
});
it('FE-STA2-HOOK-002: narrows by period and by type', async () => {
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
act(() => result.current.setPeriod('thisYear'));
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([1]);
act(() => {
result.current.setPeriod('all');
result.current.setType('kayak');
});
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([2]);
});
it('FE-STA2-HOOK-003: offline and failed reads are told apart, and a retry reads again', async () => {
const { TourActivityStatsOfflineError } = await import('../../../repo/tourActivityStatsRepo');
repo.get.mockRejectedValueOnce(new TourActivityStatsOfflineError());
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('offline'));
repo.get.mockRejectedValueOnce(new Error('boom'));
act(() => result.current.retry());
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.status).toBe('error'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(repo.get).toHaveBeenCalledTimes(3);
});
it('FE-STA2-HOOK-004: leaving a tour out shows at once and takes the server answer', async () => {
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
repo.setExcluded.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
let write: Promise<void> = Promise.resolve();
act(() => {
write = result.current.setExcluded(entry(), true);
});
expect(result.current.pending.has(1)).toBe(true);
expect(result.current.summary?.totals.count).toBe(1);
expect(repo.setExcluded).toHaveBeenCalledWith({ place_id: 1, excluded: true });
await act(async () => {
const entries = stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item));
answer({ ...stats, entries });
await write;
});
expect(result.current.pending.size).toBe(0);
expect(result.current.summary?.done.find((item) => item.place_id === 1)?.excluded).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-STA2-HOOK-005: a refused exclusion counts the tour again and says so', async () => {
repo.setExcluded.mockRejectedValue(new Error('nope'));
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
await act(async () => {
await result.current.setExcluded(entry(), true);
});
expect(result.current.summary?.totals.count).toBe(2);
expect(result.current.pending.size).toBe(0);
expect(toast.error).toHaveBeenCalledWith('tours.stats.excludeFailed');
});
it('FE-STA2-HOOK-006: a read answered after the tab went away is dropped', async () => {
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
repo.get.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const { result, unmount } = renderHook(() => useTourActivityStats(true));
expect(result.current.status).toBe('loading');
unmount();
await act(async () => answer(stats));
expect(result.current.data).toBeNull();
});
it('FE-STA2-HOOK-007: a write before the first read leaves the empty state empty', async () => {
repo.get.mockReturnValue(new Promise(() => undefined));
repo.setExcluded.mockRejectedValue(new Error('offline'));
const { result } = renderHook(() => useTourActivityStats(true));
await act(async () => {
await result.current.setExcluded(entry(), true);
});
expect(result.current.data).toBeNull();
expect(toast.error).toHaveBeenCalledTimes(1);
});
it('FE-STA2-HOOK-008: an older exclusion answered last never puts a newer one back', async () => {
const answers: Array<(value: TourActivityStatsResponse) => void> = [];
repo.setExcluded.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
const excludedOf = (placeId: number) =>
result.current.data?.entries.find((item) => item.place_id === placeId)?.excluded;
const withExcluded = (...ids: number[]) => ({
...stats,
entries: stats.entries.map((item) => ({ ...item, excluded: ids.includes(item.place_id) })),
});
let first: Promise<void> = Promise.resolve();
let second: Promise<void> = Promise.resolve();
act(() => {
first = result.current.setExcluded(entry(), true);
});
act(() => {
second = result.current.setExcluded(stats.entries[1] as TourActivityStatsEntry, true);
});
// The second answer comes first and carries both; the first, from before the second, only its own.
await act(async () => {
answers[1]?.(withExcluded(1, 2));
await second;
});
await act(async () => {
answers[0]?.(withExcluded(1));
await first;
});
expect(excludedOf(1)).toBe(true);
expect(excludedOf(2)).toBe(true);
// The other way round: the first comes first, without the second still on its way, which stays left out.
act(() => {
first = result.current.setExcluded(entry(), false);
});
act(() => {
second = result.current.setExcluded(stats.entries[1] as TourActivityStatsEntry, false);
});
await act(async () => {
answers[2]?.(withExcluded(2));
await first;
});
expect(excludedOf(1)).toBe(false);
expect(excludedOf(2)).toBe(false);
await act(async () => {
answers[3]?.(withExcluded());
await second;
});
expect(excludedOf(2)).toBe(false);
});
it('FE-STA2-HOOK-009: a read asked before an exclusion and answered after it does not undo it', async () => {
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
let read: (value: TourActivityStatsResponse) => void = () => undefined;
repo.get.mockReturnValue(new Promise((resolve) => (read = resolve)));
act(() => result.current.retry());
repo.setExcluded.mockResolvedValue({
...stats,
entries: stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item)),
});
await act(async () => {
await result.current.setExcluded(entry(), true);
});
await act(async () => read(stats));
expect(result.current.status).toBe('ready');
expect(result.current.data?.entries.find((item) => item.place_id === 1)?.excluded).toBe(true);
});
});
@@ -0,0 +1,165 @@
import {
summarizeTourActivities,
type TourActivityStatsEntry,
type TourActivityStatsResponse,
type TourActivitySummary,
} from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { TourActivityStatsOfflineError, tourActivityStatsRepo } from '../../../repo/tourActivityStatsRepo';
import { localIsoDate } from '../../../utils/localDate';
import { useToast } from '../../shared/Toast';
import {
periodOptions,
periodRange,
typeOptions,
type ActivityPeriod,
type ActivityType,
} from './tourActivityStatsModel';
/** Where the read stands: not asked yet, on its way, there, refused offline, or failed. */
type ActivityStatsStatus = 'idle' | 'loading' | 'ready' | 'offline' | 'error';
export interface TourActivityStats {
status: ActivityStatsStatus;
data: TourActivityStatsResponse | null;
/** The sums of the chosen period and type; null until the first read is there. */
summary: TourActivitySummary | null;
period: ActivityPeriod;
setPeriod: (period: ActivityPeriod) => void;
periods: ActivityPeriod[];
type: ActivityType;
setType: (type: ActivityType) => void;
types: ActivityType[];
/** The tours whose exclusion is on its way to the server. */
pending: ReadonlySet<number>;
setExcluded: (entry: TourActivityStatsEntry, excluded: boolean) => Promise<void>;
retry: () => void;
}
/** What the latest read came back with, and the attempt it answered. */
interface Answered {
attempt: number;
status: Exclude<ActivityStatsStatus, 'idle' | 'loading'>;
}
/** Every entry of the tour with the flag as asked, the rest as they were. */
function flagged(data: TourActivityStatsResponse, placeId: number, excluded: boolean): TourActivityStatsResponse {
return {
...data,
entries: data.entries.map((entry) => (entry.place_id === placeId ? { ...entry, excluded } : entry)),
};
}
/** Every entry with the flags of the exclusions still on their way laid over it, the way they show already. */
function withFlags(data: TourActivityStatsResponse, flags: ReadonlyMap<number, boolean>): TourActivityStatsResponse {
if (flags.size === 0) return data;
return {
...data,
entries: data.entries.map((entry) =>
flags.has(entry.place_id) ? { ...entry, excluded: flags.get(entry.place_id) === true } : entry
),
};
}
/**
* STA-02: the user's own tour activities in the Atlas. Read live when the tab
* is first shown (and again on retry), never cached on the device; narrowed by
* period and type here, summed by the shared summary the assistant uses too.
* Leaving a tour out shows at once and is undone with a message when the
* server refuses it; the plan of the tour never changes. Answers can come
* back in any order: only the answer to the latest read or write is taken
* whole, with the exclusions still on their way laid over it; an older one
* only confirms its own tour's flag, so it never puts back a state from
* before a newer change.
*/
export function useTourActivityStats(active: boolean): TourActivityStats {
const { t } = useTranslation();
const toast = useToast();
// The answer to the latest read and which attempt it answered: a retry is loading until its own answer is there.
const [answered, setAnswered] = useState<Answered | null>(null);
const [data, setData] = useState<TourActivityStatsResponse | null>(null);
const [period, setPeriod] = useState<ActivityPeriod>('all');
const [type, setType] = useState<ActivityType>('all');
const [pending, setPending] = useState<ReadonlySet<number>>(() => new Set());
const [attempt, setAttempt] = useState(0);
const today = localIsoDate();
// Raised by every read and every write, so an answer knows whether a newer request went out after it.
const latest = useRef(0);
// The exclusions on their way to the server, by tour: the flag the user asked for.
const inFlight = useRef(new Map<number, boolean>());
useEffect(() => {
if (!active) return;
const controller = new AbortController();
const asked = ++latest.current;
tourActivityStatsRepo.get(controller.signal).then(
(answer) => {
if (controller.signal.aborted) return;
if (asked === latest.current) setData(withFlags(answer, inFlight.current));
setAnswered({ attempt, status: 'ready' });
},
(err: unknown) => {
if (controller.signal.aborted) return;
setAnswered({ attempt, status: err instanceof TourActivityStatsOfflineError ? 'offline' : 'error' });
}
);
return () => controller.abort();
}, [active, attempt]);
let status: ActivityStatsStatus = 'loading';
if (answered?.attempt === attempt) status = answered.status;
else if (!active && !answered) status = 'idle';
const summary = useMemo(() => {
if (!data) return null;
const range = periodRange(period, today);
return summarizeTourActivities(data.entries, { today, ...range, type: type === 'all' ? null : type });
}, [data, period, type, today]);
const periods = useMemo(() => periodOptions(data?.entries ?? [], today), [data, today]);
const types = useMemo(() => typeOptions(data?.entries ?? [], today), [data, today]);
const setExcluded = useCallback(
async (entry: TourActivityStatsEntry, excluded: boolean) => {
const placeId = entry.place_id;
const asked = ++latest.current;
inFlight.current.set(placeId, excluded);
setPending((current) => new Set(current).add(placeId));
setData((current) => (current ? flagged(current, placeId, excluded) : current));
try {
const answer = await tourActivityStatsRepo.setExcluded({ place_id: placeId, excluded });
inFlight.current.delete(placeId);
if (asked === latest.current) setData(withFlags(answer, inFlight.current));
else setData((current) => (current ? flagged(current, placeId, excluded) : current));
} catch {
inFlight.current.delete(placeId);
setData((current) => (current ? flagged(current, placeId, !excluded) : current));
toast.error(t('tours.stats.excludeFailed'));
} finally {
setPending((current) => {
const next = new Set(current);
next.delete(placeId);
return next;
});
}
},
[t, toast]
);
const retry = useCallback(() => setAttempt((count) => count + 1), []);
return {
status,
data,
summary,
period,
setPeriod,
periods,
type,
setType,
types,
pending,
setExcluded,
retry,
};
}
@@ -0,0 +1,115 @@
// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-009: the card for one picked via ferrata.
import { describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import FerrataDetailCard from './FerrataDetailCard';
import { type FerrataGroup, groupFerratas } from './ferrataGroups';
/** About 222 m of latitude per segment. */
const segment = (id: number, from: number, overrides: Partial<Ferrata> = {}): Ferrata => ({
id: `way/${id}`,
name: 'Via ferrata Pisciadù',
grade: { raw: '5', level: 5, plus: '+' },
routeName: 'Sella group',
geometry: [
[from, 11.81],
[from + 0.002, 11.81],
],
osmUrl: `https://www.openstreetmap.org/way/${id}`,
...overrides,
});
const LOWER = segment(77, 46.53, { grade: { raw: '2', level: 2, plus: null } });
const UPPER = segment(78, 46.532);
function groupOf(...segments: Ferrata[]): FerrataGroup {
const [group] = groupFerratas(segments);
if (!group) throw new Error('no group');
return group;
}
describe('FerrataDetailCard', () => {
it('FE-FERRATA-CARD-001: shows the name, the route, the grade range and length as badges, and the attribution', () => {
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} />);
expect(screen.getByRole('heading', { name: 'Via ferrata Pisciadù' })).toBeInTheDocument();
expect(screen.getByText('Sella group')).toBeInTheDocument();
// A badge, not a line colour: the grade is neutral, never the tour's green to red.
expect(screen.getByText('K2 to K5+')).toHaveClass('text-content-secondary');
expect(screen.getByText('445 m')).toBeInTheDocument();
expect(screen.getByText('Data © OpenStreetMap contributors')).toBeInTheDocument();
});
it('FE-FERRATA-CARD-002: links to the way that was picked on openstreetmap.org, in a new tab', () => {
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} />);
const link = screen.getByRole('link', { name: /View in OpenStreetMap/ });
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/78');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-FERRATA-CARD-003: an unnamed ferrata takes its route name as title and does not repeat it', () => {
const unnamed = segment(77, 46.53, { name: null });
render(<FerrataDetailCard group={groupOf(unnamed)} segment={unnamed} />);
expect(screen.getByRole('heading', { name: 'Sella group' })).toBeInTheDocument();
expect(screen.getAllByText('Sella group')).toHaveLength(1);
});
it('FE-FERRATA-CARD-004: one grade reads once; a letter scale keeps its raw grade, none at all reads unknown', () => {
const { rerender } = render(<FerrataDetailCard group={groupOf(UPPER)} segment={UPPER} />);
expect(screen.getByText('K5+')).toBeInTheDocument();
const letter = segment(80, 46.53, { grade: { raw: 'C/D', level: null, plus: null } });
rerender(<FerrataDetailCard group={groupOf(letter)} segment={letter} />);
expect(screen.getByText('C/D')).toHaveClass('text-content-secondary');
const bare = segment(81, 46.53, { name: null, routeName: null, grade: { raw: null, level: null, plus: null } });
rerender(<FerrataDetailCard group={groupOf(bare)} segment={bare} />);
expect(screen.getByText('Grade unknown')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Unnamed via ferrata' })).toBeInTheDocument();
});
it('FE-FERRATA-CARD-005: offers a close button only when it can be closed', () => {
const onClose = vi.fn();
const group = groupOf(UPPER);
const { rerender } = render(<FerrataDetailCard group={group} segment={UPPER} />);
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
rerender(<FerrataDetailCard group={group} segment={UPPER} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('FE-FERRATA-CARD-006: separates nothing with a middle dot', () => {
const { container } = render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={LOWER} />);
expect(container.textContent).not.toContain('·');
});
it('FE-FERRATA-CARD-007: a long name wraps onto a second line instead of being cut to one', () => {
const long = 'Herrmann von Barth Klettersteig über den Wetterstein';
render(<FerrataDetailCard group={groupOf(segment(79, 46.6, { name: long }))} segment={UPPER} />);
const heading = screen.getByRole('heading', { name: long });
expect(heading).toHaveClass('line-clamp-2');
expect(heading).not.toHaveClass('truncate');
});
it('FE-FERRATA-CARD-008: offers "Add to tour" only where the tour can be edited, and hands over the ferrata', () => {
const group = groupOf(LOWER, UPPER);
const { rerender } = render(<FerrataDetailCard group={group} segment={UPPER} />);
expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument();
const onAdopt = vi.fn();
rerender(<FerrataDetailCard group={group} segment={UPPER} onAdopt={onAdopt} />);
fireEvent.click(screen.getByTestId('ferrata-adopt'));
expect(onAdopt).toHaveBeenCalledWith(group);
});
it('FE-FERRATA-CARD-009: a ferrata the tour already climbs says so instead of offering it again', () => {
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} onAdopt={vi.fn()} onTour />);
expect(screen.getByTestId('ferrata-on-tour')).toHaveTextContent('Part of the tour');
expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,117 @@
import { Check, ExternalLink, MapPinPlus, MoveHorizontal, Route, X } from 'lucide-react';
import type { Ferrata } from '@trek/shared';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance } from '../../utils/units';
import { MAP_CARD_GLASS } from '../Map/mapControlShadow';
import { SoftPill } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { ferrataOsmUrl } from './ferrataModel';
/**
* One via ferrata, picked on the map: its name, the route it belongs to, its
* grade range and total length as badges, and the way back to the source.
* `segment` is the OSM way that was clicked, which the source link opens.
* OpenStreetMap's data is ODbL, which asks for the attribution next to
* anything shown from it, so the card carries it rather than relying on the
* map's own corner.
*/
export default function FerrataDetailCard({
group,
segment,
onClose,
onAdopt,
onTour = false,
width,
}: {
group: FerrataGroup;
segment: Ferrata;
onClose?: () => void;
/** Puts the ferrata onto the tour being planned; absent where the tour cannot be edited. */
onAdopt?: (group: FerrataGroup) => void;
/** The tour on the map already climbs this ferrata: the card says so instead of offering it again. */
onTour?: boolean;
/** Set when the card stands on another control and takes its width. */
width?: number;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit);
const label = ferrataLabel(group, t);
const name = label.name;
// The route line only adds something when it is not already the title.
const route = group.routeName && group.routeName !== name ? group.routeName : null;
// Without any grade the badge says so, rather than leaving a gap.
const grade = label.grade ?? t('ferratas.unknownGrade');
return (
<article
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
style={{ ...MAP_CARD_GLASS, width }}
data-testid="ferrata-detail-card"
>
<div className="flex items-start gap-2">
<Route size={16} className="mt-0.5 flex-none text-content-muted" aria-hidden="true" />
<div className="min-w-0 flex-1">
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={name}>
{name}
</h3>
{route && <p className="truncate text-caption text-content-muted">{route}</p>}
</div>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="flex-none rounded-lg p-1 text-content-muted hover:bg-surface-hover hover:text-content"
>
<X size={14} aria-hidden="true" />
</button>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<SoftPill>{grade}</SoftPill>
{group.lengthM > 0 && (
<SoftPill icon={<MoveHorizontal size={11} aria-hidden="true" />}>
{formatDistance(group.lengthM / 1000, unit ?? 'metric')}
</SoftPill>
)}
</div>
{onTour ? (
<div className="mt-3 flex" data-testid="ferrata-on-tour">
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
{t('ferratas.onTour')}
</SoftPill>
</div>
) : (
onAdopt && (
<button
type="button"
onClick={() => onAdopt(group)}
data-testid="ferrata-adopt"
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-full bg-accent px-3 py-1.5 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(12, 'body')}
>
<MapPinPlus size={13} strokeWidth={2.2} aria-hidden="true" />
{t('ferratas.adopt')}
</button>
)
)}
<a
href={ferrataOsmUrl(segment)}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 font-medium text-content hover:underline"
style={fs(12, 'body')}
>
{t('ferratas.viewInOsm')}
<ExternalLink size={13} aria-hidden="true" />
</a>
<p className="mt-2 border-t border-edge-faint pt-2 text-content-faint" style={fs(9)}>
{t('ferratas.attribution')}
</p>
</article>
);
}
@@ -0,0 +1,77 @@
// FE-FERRATA-OVERLAY-001 to FE-FERRATA-OVERLAY-004: where the picked ferrata's card sits over the map, and how it closes.
import { describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import FerrataDetailOverlay from './FerrataDetailOverlay';
import { groupFerratas } from './ferrataGroups';
import type { FerrataSelection } from './useFerrataMapLayer';
const FERRATA: Ferrata = {
id: 'way/42',
name: 'Via delle Trincee',
grade: { raw: '4', level: 4, plus: null },
routeName: null,
geometry: [
[46.52, 11.8],
[46.53, 11.81],
],
osmUrl: 'https://www.openstreetmap.org/way/42',
};
const [GROUP] = groupFerratas([FERRATA]);
if (!GROUP) throw new Error('no group');
const SELECTION: FerrataSelection = { group: GROUP, segment: FERRATA };
describe('FerrataDetailOverlay', () => {
it('FE-FERRATA-OVERLAY-001: shows nothing while no ferrata is picked', () => {
render(<FerrataDetailOverlay selection={null} onClose={vi.fn()} insetEnd={400} bottom={66} />);
expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull();
});
it('FE-FERRATA-OVERLAY-002: anchors the card at the bottom of the inline end, clear of the panel and the control row', () => {
render(<FerrataDetailOverlay selection={SELECTION} onClose={vi.fn()} insetEnd={400} bottom={66} />);
const overlay = screen.getByTestId('ferrata-detail-overlay');
expect(overlay.style.insetInlineEnd).toBe('400px');
// Logical only: no physical side, so a right-to-left page mirrors it.
expect(overlay.style.right).toBe('');
expect(overlay.style.left).toBe('');
expect(overlay.style.bottom).toBe('66px');
expect(overlay).toContainElement(screen.getByTestId('ferrata-detail-card'));
});
it('FE-FERRATA-OVERLAY-003: closes on a press outside it, not on one inside, and reports the outside press', () => {
const onClose = vi.fn();
const onOutsidePress = vi.fn();
render(
<div>
<button type="button">map</button>
<FerrataDetailOverlay
selection={SELECTION}
onClose={onClose}
onOutsidePress={onOutsidePress}
insetEnd={400}
bottom={66}
/>
</div>
);
fireEvent.pointerDown(screen.getByText('Via delle Trincee'));
expect(onClose).not.toHaveBeenCalled();
expect(onOutsidePress).not.toHaveBeenCalled();
fireEvent.pointerDown(screen.getByRole('button', { name: 'map' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onOutsidePress).toHaveBeenCalledTimes(1);
});
it('FE-FERRATA-OVERLAY-004: closes on Escape and on its own close button', () => {
const onClose = vi.fn();
render(<FerrataDetailOverlay selection={SELECTION} onClose={onClose} insetEnd={400} bottom={66} />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,63 @@
import { useRef } from 'react';
import { useMapCardDismiss } from '../Map/useMapCardDismiss';
import FerrataDetailCard from './FerrataDetailCard';
import type { FerrataGroup } from './ferrataGroups';
import type { FerrataSelection } from './useFerrataMapLayer';
/**
* The picked ferrata's card over the map, anchored at the bottom of the inline
* end, above the row of map controls. `insetEnd` is what the panel on that
* side covers plus the gap the layer pill keeps to the panel on the other, so
* the card mirrors the pill and never slides under the panel; `bottom` clears
* the control row (the layer pill on the flat map, the dock and the grade
* legend in 3D).
*
* It closes on a press anywhere outside it and on Escape. A press on another
* ferrata closes this card first and the click then opens that one's. The
* press outside is handed to `onOutsidePress` as well, so the map under it can
* leave the click that follows alone (see `useOutsidePressGuard`).
*/
export default function FerrataDetailOverlay({
selection,
onClose,
onOutsidePress,
insetEnd,
bottom,
width,
onAdopt,
onTourIds,
}: {
selection: FerrataSelection | null;
onClose: () => void;
onOutsidePress?: () => void;
insetEnd: number;
bottom: number;
/** A fixed width, to match what the card stands on (the grade legend in 3D). */
width?: number;
onAdopt?: (group: FerrataGroup) => void;
/** The ferratas the tour on the map already climbs. */
onTourIds?: ReadonlySet<string>;
}) {
const ref = useRef<HTMLDivElement>(null);
useMapCardDismiss(ref, !!selection, onClose, onOutsidePress);
if (!selection) return null;
return (
<div
ref={ref}
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
data-testid="ferrata-detail-overlay"
>
<FerrataDetailCard
group={selection.group}
segment={selection.segment}
onClose={onClose}
onAdopt={onAdopt}
onTour={onTourIds?.has(selection.group.id) ?? false}
width={width}
/>
</div>
);
}
@@ -0,0 +1,42 @@
/**
* The via ferrata mark as a React element: the ladder on a disc that the map
* draws at a ferrata's midpoint, in the same `--map-ferrata` tokens, for the
* places beside the map that point at one (a waypoint at its foot or top).
*/
export function FerrataGlyph({ size = 14, className = '' }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 22 22" aria-hidden="true" className={className}>
<circle cx="11" cy="11" r="9.75" fill="var(--map-ferrata-casing)" stroke="var(--map-ferrata)" strokeWidth="1.5" />
<path
d="M8 6v10M14 6v10M8 8.5h6M8 11h6M8 13.5h6"
fill="none"
stroke="var(--map-ferrata)"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
/**
* The same ladder as a line icon, drawn the way lucide draws its icons (24 grid,
* round caps, the current colour), for controls that sit among lucide icons:
* the via ferrata switch in the map's layer pill.
*/
export function FerrataLineIcon({ size = 15, strokeWidth = 2 }: { size?: number; strokeWidth?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8 3v18M16 3v18M8 7h8M8 12h8M8 17h8" />
</svg>
);
}
@@ -0,0 +1,24 @@
import type { Map } from 'mapbox-gl';
import type { FerrataGroup } from './ferrataGroups';
import { type FerrataSelectHandler, useFerrataLayerGL } from './useFerrataLayerGL';
/**
* The via ferrata layer as a mountable piece for a GL map view: it renders
* nothing itself, the lines live on `map`. See useFerrataLayerGL for the
* layering, the highlight and the click handling.
*/
export default function FerrataLayerGL({
map,
groups,
onSelect,
selectedId = null,
}: {
map: Map | null;
groups: readonly FerrataGroup[];
onSelect: FerrataSelectHandler;
selectedId?: string | null;
}) {
useFerrataLayerGL(map, groups, onSelect, selectedId);
return null;
}
@@ -0,0 +1,269 @@
/**
* FE-FERRATA-LEAFLET-001 to FE-FERRATA-LEAFLET-011: the via ferrata layer on
* the Leaflet renderer.
*
* Leaflet itself is not under test: the mock reflects each polyline's and
* marker's props back as data attributes, so the assertions read which lines
* are drawn, in which order and with what options. Casing and line stay
* non-interactive so they cannot swallow clicks meant for places; only the
* invisible hit line on top carries the tooltip, the hover and the click.
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import FerrataLayerLeaflet from './FerrataLayerLeaflet';
import { groupFerratas } from './ferrataGroups';
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, string | number>) =>
params ? `${key}(${Object.values(params).join(',')})` : key,
}),
}));
const zoom = { value: 12 };
const zoomEnd: { handler: (() => void) | null } = { handler: null };
type Handlers = Record<string, ((event: { latlng: { lat: number; lng: number } }) => void) | undefined>;
const at = { latlng: { lat: 46.505, lng: 11.705 } };
vi.mock('react-leaflet', () => ({
Polyline: ({
positions,
pathOptions,
interactive,
bubblingMouseEvents,
eventHandlers,
children,
}: {
positions: unknown;
pathOptions: { color: string; weight: number; opacity: number; dashArray?: string; className?: string };
interactive?: boolean;
bubblingMouseEvents?: boolean;
eventHandlers?: Handlers;
children?: ReactNode;
}) => (
<div
data-testid="polyline"
data-positions={JSON.stringify(positions)}
data-color={pathOptions.color}
data-weight={pathOptions.weight}
data-opacity={pathOptions.opacity}
data-dash={pathOptions.dashArray ?? ''}
data-class={pathOptions.className ?? ''}
data-interactive={interactive === false ? 'false' : 'true'}
data-bubbles={bubblingMouseEvents === false ? 'false' : 'true'}
onClick={() => eventHandlers?.click?.(at)}
onMouseOver={() => eventHandlers?.mouseover?.(at)}
onMouseOut={() => eventHandlers?.mouseout?.(at)}
>
{children}
</div>
),
Marker: ({ position, eventHandlers }: { position: [number, number]; eventHandlers?: Handlers }) => (
<button
type="button"
data-testid="ferrata-icon"
data-position={JSON.stringify(position)}
onClick={() => eventHandlers?.click?.(at)}
/>
),
Tooltip: ({ children, className }: { children: ReactNode; className?: string }) => (
<span data-testid="tooltip" data-class={className ?? ''}>
{children}
</span>
),
// A pixel per 0.00001 degree: enough to tell icons apart that sit a few metres from each other.
useMap: () => ({
getZoom: () => zoom.value,
project: ([lat, lng]: [number, number]) => ({ x: lng * 100_000, y: -lat * 100_000 }),
}),
useMapEvents: (handlers: { zoomend?: () => void }) => {
zoomEnd.handler = handlers.zoomend ?? null;
return null;
},
}));
const ferrata = (
id: string,
name: string | null,
level: number | null,
options: { routeName?: string | null; lat?: number; lng?: number; plus?: '+' | null } = {}
): Ferrata => {
const lat = options.lat ?? 46.5;
const lng = options.lng ?? 11.7;
return {
id,
name,
grade: { raw: level === null ? null : String(level), level, plus: options.plus ?? null },
routeName: options.routeName ?? null,
geometry: [
[lat, lng],
[lat + 0.01, Math.round((lng + 0.01) * 1e6) / 1e6],
],
osmUrl: `https://www.openstreetmap.org/${id}`,
};
};
const lines = () => screen.getAllByTestId('polyline');
beforeEach(() => {
zoom.value = 12;
});
describe('FerrataLayerLeaflet', () => {
it('FE-FERRATA-LEAFLET-001: draws nothing for no ferratas', () => {
const { container } = render(<FerrataLayerLeaflet groups={[]} onSelect={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-FERRATA-LEAFLET-002: a light casing, the dark dashed line, then the invisible hit line, in [lat, lng]', () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata', 'black');
root.style.setProperty('--map-ferrata-casing', 'white');
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines().map((line) => line.dataset.weight)).toEqual(['6', '2.5', '16']);
expect(lines().map((line) => line.dataset.color)).toEqual(['white', 'black', 'white']);
expect(lines().map((line) => line.dataset.interactive)).toEqual(['false', 'false', 'true']);
expect(lines()[1]?.dataset.dash).toBe('6 5');
expect(lines()[2]?.dataset.opacity).toBe('0');
expect(JSON.parse(lines()[0]?.dataset.positions ?? '[]')).toEqual([
[46.5, 11.7],
[46.51, 11.71],
]);
root.style.removeProperty('--map-ferrata');
root.style.removeProperty('--map-ferrata-casing');
});
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade range, or falls back to the route and unnamed', () => {
const groups = groupFerratas([
ferrata('way/1', 'Pisciadù', 2),
// Picks up where way/1 ends: one ferrata in two segments.
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, lng: 11.71, plus: '+' }),
ferrata('way/3', null, 3, { routeName: 'Sentiero attrezzato', lat: 47 }),
ferrata('way/4', null, null, { lat: 48 }),
]);
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
expect(screen.getAllByTestId('tooltip').map((tip) => tip.textContent)).toEqual([
'Pisciadùferratas.gradeRange(K2,K4+)',
'Pisciadùferratas.gradeRange(K2,K4+)',
'Sentiero attrezzatoK3',
'ferratas.unnamed',
]);
});
it('FE-FERRATA-LEAFLET-004: a click on the hit line hands the ferrata, the way and the point to onSelect', () => {
const onSelect = vi.fn();
const target = ferrata('way/9', 'Tridentina', 2);
const groups = groupFerratas([target]);
render(<FerrataLayerLeaflet groups={groups} onSelect={onSelect} />);
const hit = lines()[2];
if (!hit) throw new Error('no hit line');
fireEvent.click(hit);
expect(onSelect).toHaveBeenCalledWith(groups[0], target, { lng: 11.705, lat: 46.505 });
});
it('FE-FERRATA-LEAFLET-005: the hit line keeps its click from reaching the map, so no control point lands under it', () => {
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines()[2]?.dataset.bubbles).toBe('false');
});
it('FE-FERRATA-LEAFLET-006: the grade sits beside the name as its own label, without a separator', () => {
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 4)])} onSelect={vi.fn()} />);
const tip = screen.getByTestId('tooltip');
expect(tip.textContent).not.toContain('·');
expect(tip.querySelector('span')?.textContent).toBe('K4');
});
it('FE-FERRATA-LEAFLET-007: hovering a segment lights up the whole ferrata in the deeper violet, and so does the open card', () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata-lit', 'navy');
const groups = groupFerratas([
ferrata('way/1', 'Via A', 2),
ferrata('way/2', 'Via A', 3, { lat: 46.51, lng: 11.71 }),
ferrata('way/3', 'Via B', 3, { lat: 47 }),
]);
const { rerender } = render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
// Casings 0 to 2, lines 3 to 5, hit lines 6 to 8.
const lineColours = () =>
lines()
.slice(3, 6)
.map((line) => line.dataset.color);
expect(lineColours()).not.toContain('navy');
const hit = lines()[7];
if (!hit) throw new Error('no hit line');
fireEvent.mouseOver(hit);
expect(lineColours().map((colour) => colour === 'navy')).toEqual([true, true, false]);
fireEvent.mouseOut(hit);
expect(lineColours()).not.toContain('navy');
rerender(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[1]?.id ?? null} />);
expect(lineColours().map((colour) => colour === 'navy')).toEqual([false, false, true]);
root.style.removeProperty('--map-ferrata-lit');
});
it('FE-FERRATA-LEAFLET-008: shows a route icon halfway along each ferrata only once zoomed in, and it opens the card', () => {
const onSelect = vi.fn();
const groups = groupFerratas([ferrata('way/1', 'Via A', 2), ferrata('way/2', 'Via B', 3, { lat: 47 })]);
render(<FerrataLayerLeaflet groups={groups} onSelect={onSelect} />);
expect(screen.queryAllByTestId('ferrata-icon')).toHaveLength(0);
zoom.value = 14;
act(() => zoomEnd.handler?.());
const icons = screen.getAllByTestId('ferrata-icon');
expect(icons).toHaveLength(2);
const [lat, lng] = JSON.parse(icons[0]?.dataset.position ?? '[]') as [number, number];
expect(lat).toBeCloseTo(46.505, 4);
expect(lng).toBeCloseTo(11.705, 4);
fireEvent.click(icons[1] as HTMLElement);
expect(onSelect).toHaveBeenCalledWith(groups[1], groups[1]?.segments[0], { lng: 11.705, lat: 46.505 });
});
it('FE-FERRATA-LEAFLET-009: a theme change recolours the lines without a refetch', async () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata', 'black');
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines()[1]?.dataset.color).toBe('black');
await act(async () => {
root.style.setProperty('--map-ferrata', 'ivory');
await Promise.resolve();
});
await waitFor(() => expect(lines()[1]?.dataset.color).toBe('ivory'));
root.style.removeProperty('--map-ferrata');
});
it('FE-FERRATA-LEAFLET-010: a lit ferrata is drawn solid and wider on a wider casing, the hit line and tooltip carry their classes', () => {
const groups = groupFerratas([ferrata('way/1', 'Via A', 2)]);
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[0]?.id ?? null} />);
expect(lines().map((line) => line.dataset.weight)).toEqual(['9', '4', '16']);
expect(lines()[1]?.dataset.dash).toBe('');
expect(lines()[2]?.dataset.class).toBe('trek-ferrata-hit');
expect(screen.getByTestId('tooltip').dataset.class).toBe('map-tooltip');
});
it('FE-FERRATA-LEAFLET-011: of two icons that would touch only the longer ferrata keeps its own', () => {
const short = ferrata('way/1', 'Via A', 2);
const long: Ferrata = {
...ferrata('way/2', 'Via B', 3, { lat: 46.5001 }),
geometry: [
[46.4951, 11.695],
[46.5151, 11.715],
],
};
const groups = groupFerratas([short, long]);
zoom.value = 14;
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
const icons = screen.getAllByTestId('ferrata-icon');
expect(icons).toHaveLength(1);
const [lat] = JSON.parse(icons[0]?.dataset.position ?? '[]') as [number, number];
expect(lat).toBeCloseTo(46.5051, 4);
});
});
@@ -0,0 +1,185 @@
import L from 'leaflet';
import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../i18n';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
import { ferrataPositions } from './ferrataModel';
import type { FerrataSelectHandler } from './useFerrataLayerGL';
import type { FerrataMapLayer } from './useFerrataMapLayer';
import { useFerrataPaint } from './useFerrataPaint';
/**
* The route icon for Leaflet: the shared SVG in a divIcon, painted straight
* from the tokens, so a theme change restyles it without rebuilding anything.
* Built once; it carries no user content.
*/
/** The hit line's class, which index.css keeps free of the focus ring a clicked path draws. */
const FERRATA_HIT_CLASS = 'trek-ferrata-hit';
const ICON = L.divIcon({
html: ferrataIconSvg({ line: 'var(--map-ferrata)', casing: 'var(--map-ferrata-casing)' }),
className: 'trek-ferrata-icon',
iconSize: [FERRATA_ICON_SIZE, FERRATA_ICON_SIZE],
iconAnchor: [FERRATA_ICON_SIZE / 2, FERRATA_ICON_SIZE / 2],
});
/**
* The ferratas whose icon shows: none below FERRATA_ICON_MIN_ZOOM, and above
* it only those whose icon touches no other one at this zoom, the longer
* ferrata first. Leaflet has no symbol placement of its own, so this is what
* GL's `icon-allow-overlap: false` does there. Positions are projected at the
* zoom, which a pan does not change, so only a zoom or new ferratas place again.
*/
function useIconGroups(groups: readonly FerrataGroup[]): readonly FerrataGroup[] {
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
return useMemo(() => {
if (zoom < FERRATA_ICON_MIN_ZOOM) return [];
const longestFirst = [...groups].sort((a, b) => b.lengthM - a.lengthM);
const shown = placeFerrataIcons(
longestFirst.map((group) => {
const { x, y } = map.project(group.midpoint, zoom);
return { id: group.id, x, y };
})
);
return groups.filter((group) => shown.has(group.id));
}, [map, groups, zoom]);
}
/** The tooltip's words: the ferrata's name and its grade. */
function FerrataTooltipText({ group }: { group: FerrataGroup }) {
const { t } = useTranslation();
const { name, grade } = ferrataLabel(group, t);
return (
<>
{name}
{grade && <span className="ms-1.5 font-semibold">{grade}</span>}
</>
);
}
/**
* The via ferrata layer on the Leaflet renderer, in the same construction as
* the GL layer: per OSM way a light casing and the dark dashed line, the whole
* ferrata lit (solid, wider, on a wider casing, in the highlight colour) while
* the pointer is on it or its card is open, a sticky tooltip in the map's
* tooltip look naming it with its grade, and from FERRATA_ICON_MIN_ZOOM a
* small route icon halfway along it where it does not touch another one. A
* click hands the ferrata and the way to `onSelect`, like a click on the GL
* layer does.
*/
export default function FerrataLayerLeaflet({
groups,
onSelect,
selectedId = null,
}: {
groups: readonly FerrataGroup[];
onSelect: FerrataSelectHandler;
selectedId?: string | null;
}) {
const paint = useFerrataPaint();
const iconGroups = useIconGroups(groups);
const [hovered, setHovered] = useState<string | null>(null);
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
const lines = useMemo(() => {
const casing = { color: paint.casing, weight: 6, opacity: 0.9, lineCap: 'round', lineJoin: 'round' } as const;
const litCasing = { ...casing, weight: 9, opacity: 1 } as const;
const line = {
color: paint.line,
weight: 2.5,
opacity: 1,
dashArray: '6 5',
lineCap: 'round',
lineJoin: 'round',
} as const;
// Solid: an empty dash list clears the dashes Leaflet would otherwise keep from the line's options.
const highlight = {
color: paint.highlight,
weight: 4,
opacity: 1,
dashArray: '',
lineCap: 'round',
lineJoin: 'round',
} as const;
// The class lets the stylesheet drop the focus ring a clicked SVG path keeps; the card has its own focus.
const hit = { color: paint.casing, weight: 16, opacity: 0, className: FERRATA_HIT_CLASS } as const;
return groups.flatMap((group) =>
group.segments.map((segment) => ({
group,
segment,
positions: ferrataPositions(segment),
casing,
litCasing,
line,
highlight,
hit,
}))
);
}, [groups, paint]);
if (lines.length === 0) return null;
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
return (
<>
{lines.map(({ group, segment, positions, casing, litCasing }) => (
<Polyline
key={`${segment.id}-casing`}
positions={positions}
pathOptions={lit(group) ? litCasing : casing}
interactive={false}
/>
))}
{lines.map(({ group, segment, positions, line, highlight }) => (
<Polyline
key={`${segment.id}-line`}
positions={positions}
pathOptions={lit(group) ? highlight : line}
interactive={false}
/>
))}
{/* A wide, invisible line on top carries the tooltip, the hover and the click, so a
thin dashed route is still easy to hit with a finger. The click stops here: it
opens the ferrata's card and must not also drop a control point under it. */}
{lines.map(({ group, segment, positions, hit }) => (
<Polyline
key={`${segment.id}-hit`}
positions={positions}
pathOptions={hit}
bubblingMouseEvents={false}
eventHandlers={{
click: (event) => onSelect(group, segment, { lng: event.latlng.lng, lat: event.latlng.lat }),
mouseover: () => setHovered(group.id),
mouseout: () => setHovered((current) => (current === group.id ? null : current)),
}}
>
<Tooltip sticky direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">
<FerrataTooltipText group={group} />
</Tooltip>
</Polyline>
))}
{iconGroups.map((group) => (
<Marker
key={`${group.id}-icon`}
position={group.midpoint}
icon={ICON}
keyboard={false}
eventHandlers={{
click: (event) => {
const segment = group.segments[0];
if (segment) onSelect(group, segment, { lng: event.latlng.lng, lat: event.latlng.lat });
},
}}
/>
))}
</>
);
}
/** The layer as the Leaflet map view mounts it from a `FerrataMapLayer`: drawn only while it is switched on. */
export function FerrataMapLayerLeaflet({ layer }: { layer: FerrataMapLayer | undefined }) {
if (!layer?.active) return null;
return <FerrataLayerLeaflet groups={layer.groups} onSelect={layer.onSelect} selectedId={layer.selectedId} />;
}
@@ -0,0 +1,123 @@
// FE-FERRATA-CROSS-001 to FE-FERRATA-CROSS-009: where a tour's line climbs a via ferrata, and the tiles along it.
import { describe, expect, it } from 'vitest';
import type { Ferrata } from '@trek/shared';
import { crossingLegs, ferrataEnds, ferrataTilesAlong, routeFerrataCrossings } from './ferrataCrossings';
import { groupFerratas } from './ferrataGroups';
/** About 11 m of latitude. */
const STEP = 0.0001;
function ferrata(id: number, from: [number, number], steps: number): Ferrata {
return {
id: `way/${id}`,
name: 'Via Test',
routeName: null,
grade: { raw: '3', level: 3, plus: null },
geometry: Array.from({ length: steps + 1 }, (_, i) => [from[0] + i * STEP, from[1]] as [number, number]),
osmUrl: `https://www.openstreetmap.org/way/${id}`,
};
}
/** A line north from a point, one point every STEP. */
function northFrom(from: [number, number], steps: number): [number, number][] {
return Array.from({ length: steps + 1 }, (_, i) => [from[0] + i * STEP, from[1]]);
}
describe('routeFerrataCrossings', () => {
const groups = groupFerratas([ferrata(1, [47.4, 11.0], 20)]);
it('FE-FERRATA-CROSS-001: a route climbing along the ferrata gives one stretch, measured along the route', () => {
const route = [...northFrom([47.399, 11.0], 9), ...northFrom([47.4, 11.0], 20)];
const [stretch, ...rest] = routeFerrataCrossings(route, groups);
expect(rest).toEqual([]);
expect(stretch.group.id).toBe(groups[0].id);
expect(stretch.fromM).toBeGreaterThan(95);
expect(stretch.toM - stretch.fromM).toBeGreaterThan(200);
});
it('FE-FERRATA-CROSS-002: a trail that only crosses the ferrata is no stretch', () => {
const across: [number, number][] = Array.from({ length: 21 }, (_, i) => [47.401, 10.999 + i * STEP]);
expect(routeFerrataCrossings(across, groups)).toEqual([]);
});
it('FE-FERRATA-CROSS-003: a route nowhere near a ferrata, or no route, gives nothing', () => {
expect(routeFerrataCrossings(northFrom([46.0, 10.0], 20), groups)).toEqual([]);
expect(routeFerrataCrossings(null, groups)).toEqual([]);
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 20), [])).toEqual([]);
});
it('FE-FERRATA-CROSS-004: a short gap in the data does not split the stretch', () => {
// Two pieces of one ferrata with 44 m missing between them: the route point
// in the middle of the hole is out of reach, the ones beside it are not.
const pieces = groupFerratas([ferrata(2, [47.4, 11.0], 10), ferrata(3, [47.4 + 14 * STEP, 11.0], 11)]);
expect(pieces).toHaveLength(1);
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 25), pieces)).toHaveLength(1);
});
});
describe('routeFerrataCrossings at the end of a ferrata', () => {
it('FE-FERRATA-CROSS-007: a route that only runs beside the end of a long ferrata is no stretch', () => {
const groups = groupFerratas([ferrata(1, [47.4, 11.0], 40)]);
// Ten points (about 100 m) along the ferrata's top, then away.
const route = [...northFrom([47.4033, 11.0], 9), ...northFrom([47.405, 11.0], 10)];
expect(routeFerrataCrossings(route, groups)).toEqual([]);
});
});
describe('routeFerrataCrossings beside a second ferrata', () => {
it('FE-FERRATA-CROSS-008: where two ferratas run a few metres apart, the route belongs to the nearer one', () => {
const near = ferrata(1, [47.4, 11.0], 30);
// A second ferrata about 15 m to the east, along the same stretch.
const beside = { ...ferrata(2, [47.4, 11.0002], 30), name: 'Via Beside' };
const groups = groupFerratas([beside, near]);
const [stretch, ...rest] = routeFerrataCrossings(northFrom([47.4, 11.0], 30), groups);
expect(rest).toEqual([]);
expect(stretch.group.name).toBe('Via Test');
});
});
describe('crossingLegs', () => {
it('FE-FERRATA-CROSS-009: places waypoints in order along a route that passes the same spot twice, and a stretch by its middle', () => {
// Out 300 m north and back: the turn point is waypoint b, and a and c stand at the same spot.
const samples = [0, 100, 200, 300, 400, 500, 600].map((distanceMeters, i) => ({
distanceMeters,
lat: 47.4 + (i <= 3 ? i : 6 - i) * 0.0009,
lng: 11,
}));
const waypoints = [
{ id: 'a', lat: 47.4, lng: 11 },
{ id: 'b', lat: 47.4027, lng: 11 },
{ id: 'c', lat: 47.4, lng: 11 },
];
expect(
crossingLegs(waypoints, samples, [
{ fromM: 50, toM: 250 },
{ fromM: 280, toM: 520 },
])
).toEqual(['a', 'b']);
expect(crossingLegs([], samples, [{ fromM: 50, toM: 80 }])).toEqual([null]);
});
});
describe('ferrataTilesAlong', () => {
it('FE-FERRATA-CROSS-005: names each grid tile the route passes once, and gives up past the cap', () => {
const tiles = ferrataTilesAlong(northFrom([47.4, 11.0], 20));
expect(tiles).toHaveLength(1);
const long: [number, number][] = Array.from({ length: 400 }, (_, i) => [40 + i * 0.06, 11]);
expect(ferrataTilesAlong(long)).toBeNull();
expect(ferrataTilesAlong(null)).toEqual([]);
});
});
describe('ferrataEnds', () => {
it('FE-FERRATA-CROSS-006: the two ends farthest apart, across the segments of one ferrata', () => {
const [group] = groupFerratas([ferrata(1, [47.4, 11.0], 5), ferrata(2, [47.4005, 11.0], 5)]);
const ends = ferrataEnds(group);
expect(ends).not.toBeNull();
const lats = ends ? ends.map(([lat]) => lat).sort() : [];
expect(lats[0]).toBeCloseTo(47.4, 6);
expect(lats[1]).toBeCloseTo(47.401, 6);
});
});
@@ -0,0 +1,270 @@
import { ferrataTileAt, ferrataTileKey, type FerrataTile } from '@trek/shared';
import type { FerrataGroup } from './ferrataGroups';
import { ferrataPositions } from './ferrataModel';
/**
* Where a tour's line runs along a via ferrata. The router walks a ferrata
* like any trail once the difficulty cap allows alpine routes (T4 and up), so
* a tour can climb one without anyone having picked it; this finds those
* stretches on the line the planner shows, for the warning badge and the band
* on the elevation profile.
*/
export interface FerrataCrossing {
group: FerrataGroup;
/** Where the stretch starts and ends, in metres along the route, as the elevation profile counts them. */
fromM: number;
toM: number;
}
/** How close a route point has to come to a ferrata to count as on it. */
const ON_FERRATA_M = 20;
/**
* A shorter run is a trail crossing the ferrata or meeting it at an end, not a
* climb along it. A ferrata shorter than that counts once most of it is walked.
*/
const MIN_STRETCH_M = 150;
const MIN_SHARE_OF_SHORT_FERRATA = 0.6;
/** Two stretches on the same ferrata this close together are one: a gap in the data, not a way off it. */
const JOIN_GAP_M = 30;
/** The most grid tiles a route asks for, about a thousand kilometres of line. */
const MAX_ROUTE_TILES = 200;
const EARTH_RADIUS_M = 6_371_000;
const RAD = Math.PI / 180;
function distanceM([lat1, lng1]: readonly [number, number], [lat2, lng2]: readonly [number, number]): number {
const dLat = (lat2 - lat1) * RAD;
const dLng = (lng2 - lng1) * RAD;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
}
/** The distance from a point to a polyline, in metres, on a flat projection around the point (fine at these scales). */
function distanceToLineM(point: readonly [number, number], line: readonly [number, number][]): number {
const metresPerLat = EARTH_RADIUS_M * RAD;
const metresPerLng = metresPerLat * Math.cos(point[0] * RAD);
const project = ([lat, lng]: readonly [number, number]) => [
(lng - point[1]) * metresPerLng,
(lat - point[0]) * metresPerLat,
];
let best = Number.POSITIVE_INFINITY;
for (let i = 1; i < line.length; i += 1) {
const [ax, ay] = project(line[i - 1]);
const [bx, by] = project(line[i]);
const dx = bx - ax;
const dy = by - ay;
const span = dx * dx + dy * dy;
const t = span === 0 ? 0 : Math.max(0, Math.min(1, -(ax * dx + ay * dy) / span));
best = Math.min(best, Math.hypot(ax + t * dx, ay + t * dy));
}
if (line.length === 1) {
const [x, y] = project(line[0]);
best = Math.hypot(x, y);
}
return best;
}
/** Whether a point lies inside a group's box widened by the reach. */
function nearBox(point: readonly [number, number], group: FerrataGroup): boolean {
const padLat = ON_FERRATA_M / (EARTH_RADIUS_M * RAD);
const padLng = padLat / Math.max(0.1, Math.cos(point[0] * RAD));
const { south, north, west, east } = group.bbox;
return (
point[0] >= south - padLat && point[0] <= north + padLat && point[1] >= west - padLng && point[1] <= east + padLng
);
}
/** The ferrata a route point is on, if any. */
function ferrataAt(
point: readonly [number, number],
groups: readonly FerrataGroup[],
lines: ReadonlyMap<string, [number, number][][]>
): FerrataGroup | null {
// The nearest one within reach: two ferratas often start a few metres apart at the same foot.
let nearest: FerrataGroup | null = null;
let nearestM = ON_FERRATA_M;
for (const group of groups) {
if (!nearBox(point, group)) continue;
for (const line of lines.get(group.id) ?? []) {
const gap = distanceToLineM(point, line);
if (gap <= nearestM) {
nearestM = gap;
nearest = group;
}
}
}
return nearest;
}
/**
* The stretches of the route that run along a ferrata, in route order. A
* stretch is measured between the route points that lie on the ferrata, so a
* trail that only crosses one or meets its foot stays out.
*/
export function routeFerrataCrossings(
route: readonly (readonly [number, number])[] | null | undefined,
groups: readonly FerrataGroup[]
): FerrataCrossing[] {
if (!route || route.length < 2 || groups.length === 0) return [];
const lines = new Map(groups.map((group) => [group.id, group.segments.map(ferrataPositions)]));
const stretches: FerrataCrossing[] = [];
let along = 0;
let open: FerrataCrossing | null = null;
for (let i = 0; i < route.length; i += 1) {
if (i > 0) along += distanceM(route[i - 1], route[i]);
const group = ferrataAt(route[i], groups, lines);
if (open && group === open.group) {
open.toM = along;
continue;
}
if (open) stretches.push(open);
open = group ? { group, fromM: along, toM: along } : null;
}
if (open) stretches.push(open);
const joined: FerrataCrossing[] = [];
for (const stretch of stretches) {
const last = joined[joined.length - 1];
if (last && last.group === stretch.group && stretch.fromM - last.toM <= JOIN_GAP_M) last.toM = stretch.toM;
else joined.push({ ...stretch });
}
return joined.filter(
(stretch) =>
stretch.toM - stretch.fromM >= Math.min(MIN_STRETCH_M, stretch.group.lengthM * MIN_SHARE_OF_SHORT_FERRATA)
);
}
/** The grid tiles the route runs through, so the ferratas along it can be loaded with the layer off; null past the cap. */
export function ferrataTilesAlong(
route: readonly (readonly [number, number])[] | null | undefined
): FerrataTile[] | null {
if (!route || route.length < 2) return [];
const tiles = new Map<string, FerrataTile>();
for (const [lat, lng] of route) {
const tile = ferrataTileAt(lat, lng);
tiles.set(ferrataTileKey(tile), tile);
if (tiles.size > MAX_ROUTE_TILES) return null;
}
return [...tiles.values()];
}
/** The two ends of a ferrata that lie farthest apart, the start and the top of the climb in either order. */
export function ferrataEnds(group: FerrataGroup): [[number, number], [number, number]] | null {
const ends = group.segments.flatMap((segment) => {
const line = ferrataPositions(segment);
const first = line[0];
const last = line[line.length - 1];
return first && last ? [first, last] : [];
});
let best: [[number, number], [number, number]] | null = null;
let bestM = -1;
for (let i = 0; i < ends.length; i += 1) {
for (let j = i + 1; j < ends.length; j += 1) {
const span = distanceM(ends[i], ends[j]);
if (span > bestM) {
bestM = span;
best = [ends[i], ends[j]];
}
}
}
return best;
}
/** The widest gap two pieces of one ferrata may leave between them and still be walked as one line. */
const CHAIN_GAP_M = 80;
/**
* The ferrata as one line from `from` (one of its ends) to its other end: its
* OSM pieces, which arrive in any order and either direction, put end to end,
* each turned the way the walk goes. Null when the pieces do not join up.
*/
export function ferrataLine(group: FerrataGroup, from: [number, number]): [number, number][] | null {
const pieces = group.segments.map((segment) => ferrataPositions(segment)).filter((line) => line.length >= 2);
if (pieces.length === 0) return null;
const line: [number, number][] = [];
let tail = from;
while (pieces.length > 0) {
let bestIndex = -1;
let bestReversed = false;
let bestGap = Number.POSITIVE_INFINITY;
pieces.forEach((piece, index) => {
const startGap = distanceM(tail, piece[0]);
const endGap = distanceM(tail, piece[piece.length - 1]);
if (startGap < bestGap) [bestIndex, bestReversed, bestGap] = [index, false, startGap];
if (endGap < bestGap) [bestIndex, bestReversed, bestGap] = [index, true, endGap];
});
if (bestGap > CHAIN_GAP_M) return null;
const [piece] = pieces.splice(bestIndex, 1);
const oriented = bestReversed ? [...piece].reverse() : piece;
line.push(...(line.length > 0 ? oriented.slice(1) : oriented));
tail = oriented[oriented.length - 1];
}
return line;
}
/** How close a waypoint has to stand to a ferrata's end to count as at its foot or top. */
const AT_END_M = 40;
/**
* The ferrata each waypoint stands at an end of, by waypoint id, among the
* ferratas the route climbs: the ones "Add to tour" set, or any placed there.
*/
export function waypointsAtFerrataEnds<T>(
waypoints: readonly { id: string; lat: number; lng: number }[],
crossings: readonly FerrataCrossing[],
describe: (group: FerrataGroup) => T
): Record<string, T> {
const marks: Record<string, T> = {};
const groups = [...new Map(crossings.map(({ group }) => [group.id, group])).values()];
for (const point of waypoints) {
const at = groups.find((group) =>
group.segments.some((segment) => {
const line = ferrataPositions(segment);
const first = line[0];
const last = line[line.length - 1];
return [first, last].some((end) => !!end && distanceM([point.lat, point.lng], end) <= AT_END_M);
})
);
if (at) marks[point.id] = describe(at);
}
return marks;
}
/**
* For each stretch, the waypoint it follows on the route: each waypoint is
* placed at the nearest point of the line after the waypoint before it
* (`samples`, metres along it as the profile counts them), and a stretch
* belongs to the leg its middle lies on. Null
* before the first waypoint or without any.
*/
export function crossingLegs(
waypoints: readonly { id: string; lat: number; lng: number }[],
samples: readonly { distanceMeters: number; lat?: number; lng?: number }[],
crossings: readonly Pick<FerrataCrossing, 'fromM' | 'toM'>[]
): Array<string | null> {
// In order along the line: each waypoint is looked for from where the one
// before it stands, so a route that passes a spot twice keeps its legs apart.
let from = 0;
const along = waypoints.map((point) => {
let best = Number.POSITIVE_INFINITY;
let at = from;
for (const sample of samples) {
if (sample.distanceMeters < from || sample.lat == null || sample.lng == null) continue;
const gap = distanceM([point.lat, point.lng], [sample.lat, sample.lng]);
if (gap < best) {
best = gap;
at = sample.distanceMeters;
}
}
from = at;
return { id: point.id, at };
});
return crossings.map((crossing) => {
// By its middle: the trail to a ferrata's foot often runs beside it for a few metres first.
const middle = (crossing.fromM + crossing.toM) / 2;
let after: string | null = null;
for (const point of along) if (point.at <= middle) after = point.id;
return after;
});
}
@@ -0,0 +1,149 @@
// FE-FERRATA-GROUP-001 to FE-FERRATA-GROUP-009: OSM's short segments read back as the ferratas a climber means.
import { describe, expect, it } from 'vitest';
import type { Ferrata } from '@trek/shared';
import {
FERRATA_NAME_REACH_M,
ferrataBboxesMeet,
ferrataGradeRange,
ferrataGroupOf,
ferrataGroupsInView,
groupFerratas,
} from './ferrataGroups';
/** About 111 m of latitude. */
const STEP = 0.001;
function segment(
id: number,
from: [number, number],
options: {
name?: string | null;
routeName?: string | null;
level?: number | null;
plus?: '+' | '-' | null;
raw?: string;
} = {}
): Ferrata {
const level = options.level === undefined ? 3 : options.level;
return {
id: `way/${id}`,
name: options.name === undefined ? 'Via X' : options.name,
routeName: options.routeName ?? null,
grade: { raw: options.raw ?? (level === null ? null : String(level)), level, plus: options.plus ?? null },
geometry: [from, [from[0] + STEP, from[1]]],
osmUrl: `https://www.openstreetmap.org/way/${id}`,
};
}
describe('groupFerratas', () => {
it('FE-FERRATA-GROUP-001: joins touching segments of one name into one ferrata, named after them', () => {
const groups = groupFerratas([
segment(2, [46.5, 11.7], { level: 4 }),
segment(1, [46.5 + STEP, 11.7], { level: 2 }),
segment(3, [46.5 + 2 * STEP, 11.7], { level: 3 }),
]);
expect(groups).toHaveLength(1);
const [group] = groups;
expect(group?.id).toBe('group/way/1');
expect(group?.name).toBe('Via X');
expect(group?.segments.map((s) => s.id)).toEqual(['way/2', 'way/1', 'way/3']);
expect(group?.lengthM).toBeGreaterThan(330);
expect(group?.lengthM).toBeLessThan(340);
});
it('FE-FERRATA-GROUP-002: chains segments within the reach, and keeps a homonym far away apart', () => {
const gap = (FERRATA_NAME_REACH_M - 50) / 111_195;
const groups = groupFerratas([
segment(1, [46.5, 11.7]),
segment(2, [46.5 + STEP + gap, 11.7]),
// The same name ten kilometres north is another ferrata.
segment(3, [46.6, 11.7]),
]);
expect(groups.map((g) => g.segments.map((s) => s.id))).toEqual([['way/1', 'way/2'], ['way/3']]);
});
it('FE-FERRATA-GROUP-003: reads names without case or outer spaces, and keeps every unnamed segment alone', () => {
const groups = groupFerratas([
segment(1, [46.5, 11.7], { name: 'Via X' }),
segment(2, [46.5 + STEP, 11.7], { name: ' via x ' }),
segment(3, [46.5, 11.71], { name: null }),
segment(4, [46.5 + STEP, 11.71], { name: null }),
]);
expect(groups.map((g) => g.segments.length)).toEqual([2, 1, 1]);
expect(groups[1]?.name).toBeNull();
});
it('FE-FERRATA-GROUP-004: groups by the route relation first, over a wider reach and whatever the ways are named', () => {
const groups = groupFerratas([
segment(10, [46.5, 11.7], { routeName: 'Brigata Tridentina', name: 'Pitch 1' }),
// 1.5 km on, the same route.
segment(11, [46.5135, 11.7], { routeName: 'Brigata Tridentina', name: null }),
// A way named like the route but outside the relation is its own ferrata.
segment(12, [46.5 + STEP, 11.7], { name: 'Brigata Tridentina' }),
]);
expect(groups).toHaveLength(2);
expect(groups[0]?.segments.map((s) => s.id)).toEqual(['way/10', 'way/11']);
expect(groups[0]?.routeName).toBe('Brigata Tridentina');
expect(groups[0]?.name).toBe('Pitch 1');
});
it('FE-FERRATA-GROUP-005: spans the grades from the easiest to the hardest segment, minus before plus', () => {
const [group] = groupFerratas([
segment(1, [46.5, 11.7], { level: 4, plus: '+' }),
segment(2, [46.5 + STEP, 11.7], { level: 2, plus: '-' }),
segment(3, [46.5 + 2 * STEP, 11.7], { level: 2 }),
segment(4, [46.5 + 3 * STEP, 11.7], { level: null }),
]);
expect(group && ferrataGradeRange(group)).toEqual({ from: 'K2-', to: 'K4+' });
expect(group?.rawGrade).toBeNull();
});
it('FE-FERRATA-GROUP-006: without a numeric grade keeps the tag as written, and has no range', () => {
const [group] = groupFerratas([segment(1, [46.5, 11.7], { level: null, raw: 'C/D' })]);
expect(group && ferrataGradeRange(group)).toBeNull();
expect(group?.rawGrade).toBe('C/D');
const [single] = groupFerratas([segment(2, [46.5, 11.7], { level: 3 })]);
expect(single && ferrataGradeRange(single)).toEqual({ from: 'K3', to: 'K3' });
});
it('FE-FERRATA-GROUP-007: puts the label halfway along the longest segment and spans every segment in its box', () => {
const long: Ferrata = {
...segment(1, [46.5, 11.7]),
geometry: [
[46.5, 11.7],
[46.5 + 4 * STEP, 11.7],
],
};
const [group] = groupFerratas([segment(2, [46.5 + 4 * STEP, 11.7]), long]);
expect(group?.midpoint[0]).toBeCloseTo(46.502, 5);
expect(group?.midpoint[1]).toBeCloseTo(11.7, 5);
expect(group?.bbox.south).toBe(46.5);
expect(group?.bbox.north).toBeCloseTo(46.505, 9);
expect(group?.bbox.west).toBe(11.7);
expect(group?.bbox.east).toBe(11.7);
});
it('FE-FERRATA-GROUP-008: finds the ferrata a way belongs to, and counts the ferratas reaching into a view', () => {
const groups = groupFerratas([
segment(1, [46.5, 11.7]),
segment(2, [46.5 + STEP, 11.7]),
segment(3, [46.6, 11.8], { name: 'Other' }),
]);
expect(ferrataGroupOf(groups, 'way/2')?.id).toBe('group/way/1');
expect(ferrataGroupOf(groups, 'way/99')).toBeNull();
expect(ferrataGroupOf(groups, null)).toBeNull();
expect(ferrataGroupsInView(groups, { south: 46.49, west: 11.69, north: 46.51, east: 11.71 })).toBe(1);
expect(ferrataGroupsInView(groups, { south: 46.4, west: 11.6, north: 46.7, east: 11.9 })).toBe(2);
expect(ferrataGroupsInView(groups, null)).toBe(0);
});
it('FE-FERRATA-GROUP-009: boxes meet on a shared edge, not across a gap; a line under two points is dropped', () => {
const box = { south: 0, west: 0, north: 1, east: 1 };
expect(ferrataBboxesMeet(box, { south: 1, west: 1, north: 2, east: 2 })).toBe(true);
expect(ferrataBboxesMeet(box, { south: 1.1, west: 0, north: 2, east: 1 })).toBe(false);
const point = { ...segment(1, [46.5, 11.7]), geometry: [[46.5, 11.7]] } as Ferrata;
expect(groupFerratas([point])).toEqual([]);
});
});
@@ -0,0 +1,270 @@
import type { Ferrata, FerrataBbox, FerrataGrade } from '@trek/shared';
import { ferrataGradeLabel, ferrataPositions } from './ferrataModel';
/**
* OpenStreetMap maps one via ferrata as many short ways: the approach, each
* pitch, a bridge, the exit. This turns those segments back into the routes a
* climber means, so the layer counts, highlights and describes one ferrata
* rather than a dozen pieces of it. Pure and React-free.
*
* Segments belong together when they share their route relation's name, or,
* with no relation, their own name, and lie close to each other: a chain of
* segments joins as long as each one comes within the reach of the next.
* Unnamed segments stay on their own; nothing says two of them are one route.
*/
/** How close two segments of the same name have to come to read as one ferrata. */
export const FERRATA_NAME_REACH_M = 300;
/**
* The same for a route relation's name. A relation is one route by definition,
* so the reach is wider; it only keeps two distant routes that happen to share
* a name apart.
*/
const FERRATA_ROUTE_REACH_M = 2000;
export interface FerrataGroup {
/** Stable for as long as the group keeps its lowest way: `group/<way id>`. */
id: string;
name: string | null;
routeName: string | null;
/** The OSM ways it is made of, in the order they arrived. */
segments: readonly Ferrata[];
/** The easiest and the hardest numeric grade among the segments, or null when none has one. */
grades: { min: FerrataGrade; max: FerrataGrade } | null;
/** A grade as tagged when no segment has a numeric one (a letter scale such as "C/D"). */
rawGrade: string | null;
/** The summed length of every segment, in metres. */
lengthM: number;
bbox: FerrataBbox;
/** Where its label goes: halfway along its longest segment, as [lat, lng]. */
midpoint: [number, number];
}
const EARTH_RADIUS_M = 6_371_000;
const RAD = Math.PI / 180;
function distanceM([lat1, lng1]: [number, number], [lat2, lng2]: [number, number]): number {
const dLat = (lat2 - lat1) * RAD;
const dLng = (lng2 - lng1) * RAD;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
}
function lengthOf(points: readonly [number, number][]): number {
let total = 0;
for (let i = 1; i < points.length; i += 1) total += distanceM(points[i - 1], points[i]);
return total;
}
/**
* The box around the points, the ones that are not finite numbers skipped, or
* null when none is left.
*/
export function ferrataPointsBbox(points: Iterable<readonly [number, number]>): FerrataBbox | null {
let south = Infinity;
let west = Infinity;
let north = -Infinity;
let east = -Infinity;
for (const [lat, lng] of points) {
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
south = Math.min(south, lat);
north = Math.max(north, lat);
west = Math.min(west, lng);
east = Math.max(east, lng);
}
return south <= north && west <= east ? { south, west, north, east } : null;
}
/** A segment's box; the schema guarantees it at least two placed points. */
function bboxOf(points: readonly [number, number][]): FerrataBbox {
return ferrataPointsBbox(points) ?? { south: 0, west: 0, north: 0, east: 0 };
}
/** Whether two boxes share any point, edges included. */
export function ferrataBboxesMeet(a: FerrataBbox, b: FerrataBbox): boolean {
return a.south <= b.north && b.south <= a.north && a.west <= b.east && b.west <= a.east;
}
/** The point halfway along a line, by length. */
function halfway(points: readonly [number, number][]): [number, number] {
const half = lengthOf(points) / 2;
let walked = 0;
for (let i = 1; i < points.length; i += 1) {
const step = distanceM(points[i - 1], points[i]);
if (walked + step >= half && step > 0) {
const t = (half - walked) / step;
const [lat1, lng1] = points[i - 1];
const [lat2, lng2] = points[i];
return [lat1 + (lat2 - lat1) * t, lng1 + (lng2 - lng1) * t];
}
walked += step;
}
return points[0] ?? [0, 0];
}
interface Segment {
ferrata: Ferrata;
points: [number, number][];
bbox: FerrataBbox;
lengthM: number;
}
/** Whether two boxes come within `reach` metres of each other, a cheap test before any point is compared. */
function boxesWithin(a: FerrataBbox, b: FerrataBbox, reach: number): boolean {
const latPad = reach / (EARTH_RADIUS_M * RAD);
const lngPad = latPad / Math.max(0.01, Math.cos(((a.south + a.north) / 2) * RAD));
return (
a.south - latPad <= b.north && b.south - latPad <= a.north && a.west - lngPad <= b.east && b.west - lngPad <= a.east
);
}
/** Whether an end of either segment comes within `reach` of a point of the other: they touch or nearly do. */
function segmentsMeet(a: Segment, b: Segment, reach: number): boolean {
if (!boxesWithin(a.bbox, b.bbox, reach)) return false;
const near = (ends: Segment, line: Segment) => {
const tips = [ends.points[0], ends.points[ends.points.length - 1]];
return tips.some((tip) => tip !== undefined && line.points.some((point) => distanceM(tip, point) <= reach));
};
return near(a, b) || near(b, a);
}
/** What segments group by, or null for one that stays alone. Case and outer spaces do not tell two names apart. */
function groupKey(ferrata: Ferrata): { key: string; reach: number } | null {
const route = ferrata.routeName?.trim().toLocaleLowerCase();
if (route) return { key: `route:${route}`, reach: FERRATA_ROUTE_REACH_M };
const name = ferrata.name?.trim().toLocaleLowerCase();
if (name) return { key: `name:${name}`, reach: FERRATA_NAME_REACH_M };
return null;
}
/** Segments of one key joined into chains: any two that meet end up in one set. */
function chains(segments: Segment[], reach: number): Segment[][] {
const parent = segments.map((_, index) => index);
const find = (i: number): number => {
let root = i;
while (parent[root] !== root) root = parent[root];
parent[i] = root;
return root;
};
for (let i = 0; i < segments.length; i += 1) {
for (let j = i + 1; j < segments.length; j += 1) {
if (find(i) !== find(j) && segmentsMeet(segments[i], segments[j], reach)) parent[find(j)] = find(i);
}
}
const sets = new Map<number, Segment[]>();
segments.forEach((segment, index) => {
const root = find(index);
const set = sets.get(root);
if (set) set.push(segment);
else sets.set(root, [segment]);
});
return [...sets.values()];
}
/** -, none, + in that order within a level. */
const gradeRank = (grade: FerrataGrade) =>
(grade.level ?? 0) * 3 + (grade.plus === '-' ? 0 : grade.plus === '+' ? 2 : 1);
function wayNumber(id: string): number {
const n = Number(id.slice(id.indexOf('/') + 1));
return Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER;
}
function toGroup(members: Segment[]): FerrataGroup {
const segments = members.map((member) => member.ferrata);
const graded = segments.map((segment) => segment.grade).filter((grade) => grade.level !== null);
const sorted = [...graded].sort((a, b) => gradeRank(a) - gradeRank(b));
const longest = members.reduce((best, member) => (member.lengthM > best.lengthM ? member : best), members[0]);
// The name of the longest named segment speaks for the group.
const named = [...members].sort((a, b) => b.lengthM - a.lengthM).find((member) => member.ferrata.name);
const lowest = [...segments].sort((a, b) => wayNumber(a.id) - wayNumber(b.id))[0];
return {
id: `group/${lowest.id}`,
name: named?.ferrata.name ?? null,
routeName: segments.find((segment) => segment.routeName)?.routeName ?? null,
segments,
grades: sorted.length ? { min: sorted[0], max: sorted[sorted.length - 1] } : null,
rawGrade: sorted.length ? null : (segments.find((segment) => segment.grade.raw)?.grade.raw ?? null),
lengthM: members.reduce((sum, member) => sum + member.lengthM, 0),
bbox: members.slice(1).reduce(
(box, member) => ({
south: Math.min(box.south, member.bbox.south),
west: Math.min(box.west, member.bbox.west),
north: Math.max(box.north, member.bbox.north),
east: Math.max(box.east, member.bbox.east),
}),
members[0].bbox
),
midpoint: halfway(longest.points),
};
}
/** The segments as ferratas, in the order their first segment arrived. */
export function groupFerratas(ferratas: readonly Ferrata[]): FerrataGroup[] {
const keyed = new Map<string, { reach: number; segments: Segment[] }>();
const ordered: Array<Segment[] | string> = [];
for (const ferrata of ferratas) {
const points = ferrataPositions(ferrata);
if (points.length < 2) continue;
const segment: Segment = { ferrata, points, bbox: bboxOf(points), lengthM: lengthOf(points) };
const key = groupKey(ferrata);
if (!key) {
ordered.push([segment]);
continue;
}
const bucket = keyed.get(key.key);
if (bucket) bucket.segments.push(segment);
else {
keyed.set(key.key, { reach: key.reach, segments: [segment] });
ordered.push(key.key);
}
}
return ordered.flatMap((entry) => {
if (typeof entry !== 'string') return [toGroup(entry)];
const bucket = keyed.get(entry);
return bucket ? chains(bucket.segments, bucket.reach).map(toGroup) : [];
});
}
/** The group a way belongs to, for a click or a selection that names one segment. */
export function ferrataGroupOf(groups: readonly FerrataGroup[], segmentId: string | null): FerrataGroup | null {
if (!segmentId) return null;
return groups.find((group) => group.segments.some((segment) => segment.id === segmentId)) ?? null;
}
/** How many ferratas reach into the area on screen. */
export function ferrataGroupsInView(groups: readonly FerrataGroup[], view: FerrataBbox | null): number {
if (!view) return 0;
return groups.reduce((count, group) => (ferrataBboxesMeet(group.bbox, view) ? count + 1 : count), 0);
}
/**
* The group's grade on the K scale: the easiest and the hardest segment
* (`K2`, `K4`), the same label twice when they agree, null when no segment has
* a numeric grade.
*/
export function ferrataGradeRange(group: Pick<FerrataGroup, 'grades'>): { from: string; to: string } | null {
if (!group.grades) return null;
const from = ferrataGradeLabel(group.grades.min);
const to = ferrataGradeLabel(group.grades.max);
return from && to ? { from, to } : null;
}
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* What a ferrata is called on the map and on its card: its own name, else its
* route's, else "unnamed"; and its grade, `K3` or `K2 to K4`, or without a
* numeric grade the tag as written (a letter scale such as "C/D" still says
* something), or null when there is none at all.
*/
export function ferrataLabel(
group: Pick<FerrataGroup, 'name' | 'routeName' | 'grades' | 'rawGrade'>,
t: Translate
): { name: string; grade: string | null } {
const name = group.name ?? group.routeName ?? t('ferratas.unnamed');
const range = ferrataGradeRange(group);
if (!range) return { name, grade: group.rawGrade };
return { name, grade: range.from === range.to ? range.from : t('ferratas.gradeRange', range) };
}
@@ -0,0 +1,66 @@
// FE-FERRATA-ICON-001 to FE-FERRATA-ICON-004: the route icon halfway along a via ferrata.
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
FERRATA_ICON_GAP,
FERRATA_ICON_PIXEL_RATIO,
FERRATA_ICON_SIZE,
ferrataIconSvg,
loadFerrataIcon,
placeFerrataIcons,
} from './ferrataIcon';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('ferrataIcon', () => {
it('FE-FERRATA-ICON-001: draws a ladder in the line colour on a disc of the casing colour', () => {
const svg = ferrataIconSvg({ line: 'var(--text-primary)', casing: 'rgb(255, 255, 255)' });
expect(svg).toContain('fill="rgb(255, 255, 255)"');
expect(svg).toContain('stroke="var(--text-primary)"');
expect(svg).toContain(`width="${FERRATA_ICON_SIZE}"`);
});
it('FE-FERRATA-ICON-002: lets nothing but a colour into the markup', () => {
const svg = ferrataIconSvg({ line: '"/><script>alert(1)</script>', casing: 'white' });
expect(svg).not.toContain('<script>');
expect(svg).toContain('stroke="currentColor"');
});
it('FE-FERRATA-ICON-003: hands GL a sharp image of the same SVG, and rejects when it cannot be decoded', async () => {
const made: Array<{ src: string; width?: number }> = [];
class FakeImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(public width?: number) {
made.push(this as unknown as { src: string; width?: number });
}
set src(value: string) {
(this as unknown as { _src: string })._src = value;
queueMicrotask(() => (value.includes('fail') ? this.onerror?.() : this.onload?.()));
}
get src() {
return (this as unknown as { _src: string })._src;
}
}
vi.stubGlobal('Image', FakeImage);
const image = await loadFerrataIcon({ line: 'black', casing: 'white' });
expect(image).toBe(made[0]);
expect(made[0]?.width).toBe(FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
expect(decodeURIComponent(made[0]?.src ?? '')).toContain('stroke="black"');
await expect(loadFerrataIcon({ line: 'fail', casing: 'white' })).rejects.toThrow('could not be drawn');
});
it('FE-FERRATA-ICON-004: leaves out an icon that would touch one already placed, first come first placed', () => {
const placed = placeFerrataIcons([
{ id: 'a', x: 100, y: 100 },
{ id: 'b', x: 100 + FERRATA_ICON_GAP - 1, y: 100 },
{ id: 'c', x: 100, y: 100 + FERRATA_ICON_GAP },
{ id: 'd', x: 400, y: 400 },
]);
expect([...placed]).toEqual(['a', 'c', 'd']);
expect(FERRATA_ICON_GAP).toBeGreaterThan(FERRATA_ICON_SIZE);
});
});
@@ -0,0 +1,79 @@
import type { FerrataPaint } from './ferrataPaint';
/**
* The small route icon a via ferrata carries at its midpoint once the map is
* zoomed in: a ladder in the line colour on a disc of the casing colour, so it
* reads as part of the line it sits on.
*
* One SVG for both renderers: Leaflet puts it in a divIcon, where it can take
* `var(--token)` straight from the page; GL needs an image with the colours
* resolved, which `loadFerrataIcon` makes from the resolved paint.
*/
/** The zoom from which the icons show; below it they would crowd a valley full of ferratas. */
export const FERRATA_ICON_MIN_ZOOM = 13;
/** The icon's size on screen, in CSS pixels. */
export const FERRATA_ICON_SIZE = 22;
/** The name the GL renderer registers the image under. */
export const FERRATA_ICON_IMAGE = 'trek-ferrata-icon';
/**
* A colour as it may go into the markup: a resolved token (a hex, `rgb()` or
* named value) or a `var(--token)`. Anything else, which a token never
* resolves to, falls back to `currentColor` rather than reaching the SVG.
*/
function safeColour(value: string): string {
return /^[#\w\s(),.%-]+$/.test(value) ? value : 'currentColor';
}
export function ferrataIconSvg(paint: Pick<FerrataPaint, 'line' | 'casing'>, size = FERRATA_ICON_SIZE): string {
const line = safeColour(paint.line);
const casing = safeColour(paint.casing);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 22 22" aria-hidden="true">`,
`<circle cx="11" cy="11" r="9.75" fill="${casing}" stroke="${line}" stroke-width="1.5"/>`,
`<path d="M8 6v10M14 6v10M8 8.5h6M8 11h6M8 13.5h6" fill="none" stroke="${line}" stroke-width="1.6" stroke-linecap="round"/>`,
'</svg>',
].join('');
}
/** How sharp the GL image is drawn; the map scales it back to FERRATA_ICON_SIZE. */
export const FERRATA_ICON_PIXEL_RATIO = 2;
/**
* The icon as an image for a GL map, in the colours the tokens resolve to now.
* Rejects when the browser cannot decode it, and the lines then simply go
* without their icons.
*/
export function loadFerrataIcon(paint: Pick<FerrataPaint, 'line' | 'casing'>): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image(FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO, FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('The via ferrata icon could not be drawn'));
const svg = ferrataIconSvg(paint, FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
});
}
/** The room an icon keeps to the next one, in CSS pixels, so two discs never touch. */
export const FERRATA_ICON_GAP = FERRATA_ICON_SIZE + 4;
/**
* Which icons fit on screen without touching one another, for a renderer that
* does not place symbols itself (Leaflet; GL keeps `icon-allow-overlap` off for
* the same effect). `points` are the icons' screen positions in pixels, in the
* order they should win: an icon closer than FERRATA_ICON_GAP to one already
* placed is left out.
*/
export function placeFerrataIcons(points: ReadonlyArray<{ id: string; x: number; y: number }>): Set<string> {
const placed: Array<{ x: number; y: number }> = [];
const ids = new Set<string>();
for (const point of points) {
if (placed.some((other) => Math.hypot(other.x - point.x, other.y - point.y) < FERRATA_ICON_GAP)) continue;
placed.push(point);
ids.add(point.id);
}
return ids;
}
@@ -0,0 +1,97 @@
// FE-FERRATA-MODEL-001 to FE-FERRATA-MODEL-007: the pure half of the via ferrata layer.
import { describe, expect, it } from 'vitest';
import { FERRATA_BBOX_MAX_SPAN_DEG, type Ferrata } from '@trek/shared';
import { groupFerratas } from './ferrataGroups';
import {
ferrataBboxFits,
ferrataBboxParam,
ferrataFeatureCollection,
ferrataGradeLabel,
ferrataLabelCollection,
ferrataOsmUrl,
ferrataPositions,
} from './ferrataModel';
const grade = (level: number | null, plus: '+' | '-' | null = null, raw: string | null = null) => ({
level,
plus,
raw,
});
const FERRATA: Ferrata = {
id: 'way/123',
name: 'Pisciadù',
grade: grade(3, '+', '3+'),
routeName: 'Brigata Tridentina',
geometry: [
[46.53, 11.81],
[46.54, 11.82],
],
osmUrl: 'https://www.openstreetmap.org/way/123',
};
describe('ferrataModel', () => {
it('FE-FERRATA-MODEL-001: labels a numeric grade on the K scale with its plus or minus', () => {
expect(ferrataGradeLabel(grade(3))).toBe('K3');
expect(ferrataGradeLabel(grade(4, '+'))).toBe('K4+');
expect(ferrataGradeLabel(grade(2, '-'))).toBe('K2-');
expect(ferrataGradeLabel(grade(0))).toBe('K0');
});
it('FE-FERRATA-MODEL-002: has no label for a way without a numeric level, letter scales included', () => {
expect(ferrataGradeLabel(grade(null))).toBeNull();
expect(ferrataGradeLabel(grade(null, null, 'C/D'))).toBeNull();
});
it('FE-FERRATA-MODEL-003: takes an area only up to the server limit and only a real box', () => {
const max = FERRATA_BBOX_MAX_SPAN_DEG;
expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max, east: 11 + max })).toBe(true);
expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max + 0.01, east: 11.2 })).toBe(false);
expect(ferrataBboxFits({ south: 46, west: 11, north: 46.2, east: 11 + max + 0.01 })).toBe(false);
expect(ferrataBboxFits({ south: 46.2, west: 11, north: 46.2, east: 11.2 })).toBe(false);
// The server's own rule: a 0.500 span that reads 0.5000000000000071 still goes out.
expect(ferrataBboxFits({ south: 63.501, west: 11, north: 64.001, east: 11.2 })).toBe(true);
});
it('FE-FERRATA-MODEL-004: writes the bbox parameter in south,west,north,east order', () => {
expect(ferrataBboxParam({ south: 46.1, west: 11.25, north: 46.3, east: 11.5 })).toBe('46.100,11.250,46.300,11.500');
});
it('FE-FERRATA-MODEL-005: builds one GeoJSON line per way, in [lng, lat], carrying only the way and its group', () => {
const collection = ferrataFeatureCollection(groupFerratas([FERRATA]));
expect(collection).toEqual({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { id: 'way/123', group: 'group/way/123' },
geometry: {
type: 'LineString',
coordinates: [
[11.81, 46.53],
[11.82, 46.54],
],
},
},
],
});
expect(ferrataPositions(FERRATA)).toEqual(FERRATA.geometry);
expect(ferrataFeatureCollection([]).features).toEqual([]);
});
it('FE-FERRATA-MODEL-006: puts one label point per ferrata halfway along it, in [lng, lat]', () => {
const [feature] = ferrataLabelCollection(groupFerratas([FERRATA])).features;
expect(feature?.properties).toEqual({ group: 'group/way/123' });
expect(feature?.geometry.type).toBe('Point');
const [lng, lat] = feature?.geometry.coordinates ?? [0, 0];
expect(lng).toBeCloseTo(11.815, 4);
expect(lat).toBeCloseTo(46.535, 4);
});
it('FE-FERRATA-MODEL-007: links only ever to openstreetmap.org, whatever the answer sent as osmUrl', () => {
expect(ferrataOsmUrl(FERRATA)).toBe('https://www.openstreetmap.org/way/123');
expect(ferrataOsmUrl({ id: 'javascript:alert(1)' })).toBe('https://www.openstreetmap.org/');
});
});
@@ -0,0 +1,109 @@
import { type Ferrata, type FerrataBbox, type FerrataGrade, ferrataBboxSpanOk } from '@trek/shared';
import type { FerrataGroup } from './ferrataGroups';
/**
* The pure half of the via ferrata layer: grade wording, the area checks a
* request makes, and the GeoJSON the GL renderer draws. React-free, so both
* renderers and the detail card read the same answers.
*/
/**
* The grade as the K scale reads it: `K3`, `K4+`, `K2-`. Null when the way has
* no numeric level (untagged, or a letter scale such as "C/D" the server keeps
* as `raw` and does not convert).
*/
export function ferrataGradeLabel(grade: FerrataGrade): string | null {
if (grade.level === null || grade.level === undefined) return null;
return `K${grade.level}${grade.plus ?? ''}`;
}
/**
* The line as `[lat, lng]` pairs Leaflet takes. Zod's tuple reads as "maybe two
* numbers, maybe more" where the client compiles without strict mode, so the
* pairs are narrowed here rather than cast; the schema has already guaranteed
* both are numbers.
*/
export function ferrataPositions(ferrata: Pick<Ferrata, 'geometry'>): Array<[number, number]> {
return ferrata.geometry.flatMap((pair): Array<[number, number]> =>
typeof pair[0] === 'number' && typeof pair[1] === 'number' ? [[pair[0], pair[1]]] : []
);
}
const OSM_ORIGIN = 'https://www.openstreetmap.org/';
/**
* The way's page on openstreetmap.org. Built from the id the contract pins to
* `way/<digits>` rather than taken from `osmUrl` as sent, so the link can only
* ever point at OpenStreetMap whatever an Overpass mirror put in the answer.
*/
export function ferrataOsmUrl(ferrata: Pick<Ferrata, 'id'>): string {
return /^way\/\d+$/.test(ferrata.id) ? `${OSM_ORIGIN}${ferrata.id}` : OSM_ORIGIN;
}
/**
* Whether the server takes this area: a real box no wider than
* FERRATA_BBOX_MAX_SPAN_DEG per side. Past it the layer asks the user to zoom
* in instead of loading every tile of the view.
*/
export function ferrataBboxFits(bbox: FerrataBbox): boolean {
// The span check is the server's own, so a box the client sends is never refused as too large.
return bbox.north > bbox.south && bbox.east > bbox.west && ferrataBboxSpanOk(bbox);
}
/** The `bbox` query parameter, in the south,west,north,east order the contract reads. */
export function ferrataBboxParam(bbox: FerrataBbox): string {
return [bbox.south, bbox.west, bbox.north, bbox.east].map((edge) => edge.toFixed(3)).join(',');
}
interface FerrataLineFeature {
type: 'Feature';
properties: { id: string; group: string };
geometry: { type: 'LineString'; coordinates: Array<[number, number]> };
}
interface FerrataLabelFeature {
type: 'Feature';
properties: { group: string };
geometry: { type: 'Point'; coordinates: [number, number] };
}
interface FeatureCollection<F> {
type: 'FeatureCollection';
features: F[];
}
const lngLat = ([lat, lng]: [number, number]): [number, number] => [lng, lat];
/**
* The segments as a GeoJSON FeatureCollection for MapLibre or Mapbox GL, one
* line per OSM way. The contract carries [lat, lng] like the rest of TREK;
* GeoJSON wants [lng, lat], and this is the one place the layer flips it. Only
* the way and its group ride along: a click looks the ferrata up by id, so
* names never pass through the map's own feature state, and a highlight
* filters on the group.
*/
export function ferrataFeatureCollection(groups: readonly FerrataGroup[]): FeatureCollection<FerrataLineFeature> {
return {
type: 'FeatureCollection',
features: groups.flatMap((group) =>
group.segments.map((segment) => ({
type: 'Feature' as const,
properties: { id: segment.id, group: group.id },
geometry: { type: 'LineString' as const, coordinates: ferrataPositions(segment).map(lngLat) },
}))
),
};
}
/** One point per ferrata, halfway along it, where the GL renderer puts the route icon. */
export function ferrataLabelCollection(groups: readonly FerrataGroup[]): FeatureCollection<FerrataLabelFeature> {
return {
type: 'FeatureCollection',
features: groups.map((group) => ({
type: 'Feature' as const,
properties: { group: group.id },
geometry: { type: 'Point' as const, coordinates: lngLat(group.midpoint) },
})),
};
}
@@ -0,0 +1,44 @@
// FE-FERRATA-PAINT-001 to FE-FERRATA-PAINT-003: the line colours come from the --map-ferrata tokens.
import { afterEach, describe, expect, it } from 'vitest';
import { ferrataPaint, sameFerrataPaint } from './ferrataPaint';
const root = document.documentElement;
afterEach(() => {
for (const name of [
'--map-ferrata',
'--map-ferrata-casing',
'--map-ferrata-lit',
'--success',
'--warning',
'--danger',
])
root.style.removeProperty(name);
});
describe('ferrataPaint', () => {
it('FE-FERRATA-PAINT-001: draws the line, its halo and the lit line in the via ferrata tokens', () => {
root.style.setProperty('--map-ferrata', 'violet');
root.style.setProperty('--map-ferrata-casing', 'white');
root.style.setProperty('--map-ferrata-lit', 'indigo');
expect(ferrataPaint()).toEqual({ line: 'violet', casing: 'white', highlight: 'indigo' });
});
it('FE-FERRATA-PAINT-002: never takes the grade colours of the tour line', () => {
root.style.setProperty('--success', 'green');
root.style.setProperty('--warning', 'orange');
root.style.setProperty('--danger', 'crimson');
const paint = Object.values(ferrataPaint());
expect(paint).not.toContain('green');
expect(paint).not.toContain('orange');
expect(paint).not.toContain('crimson');
});
it('FE-FERRATA-PAINT-003: falls back to currentColor where a token is not defined, and compares by colour', () => {
const paint = ferrataPaint();
expect(paint).toEqual({ line: 'currentColor', casing: 'currentColor', highlight: 'currentColor' });
expect(sameFerrataPaint(paint, { ...paint })).toBe(true);
expect(sameFerrataPaint(paint, { ...paint, highlight: 'red' })).toBe(false);
});
});
@@ -0,0 +1,40 @@
import { readMapPaint, sameMapPaint } from '../Map/mapPaint';
/**
* The colours the via ferrata lines are drawn in.
*
* Ferratas have a look of their own: a dashed violet line on a white halo.
* Violet is the one colour nothing else on the planner map uses (the route is
* blue, its grades run green to red, the waypoints are blue), and the halo
* keeps it readable over a satellite tile or dark hillshade. The ferrata under
* the pointer or open in the card is drawn solid, wider and in a deeper violet.
* The grade is a badge on the card, never a line colour.
*
* Map paint cannot take a `var(--token)`: Leaflet writes the colour into an
* SVG `stroke` attribute and GL paint wants a resolved colour string. So the
* `--map-ferrata*` tokens are read off the document when the layer draws
* (`mapPaint.ts`; the precedent is the hazard overlay in useHazardLayerGL),
* which keeps light and dark mode apart without a colour literal in the layer.
*/
export interface FerrataPaint {
/** The dashed line itself. */
line: string;
/** The halo under the line, so it stays readable over a satellite or hillshade tile. */
casing: string;
/** The ferrata under the pointer or open in the card. */
highlight: string;
}
export const FERRATA_TOKENS = {
line: '--map-ferrata',
casing: '--map-ferrata-casing',
highlight: '--map-ferrata-lit',
} as const;
export function ferrataPaint(): FerrataPaint {
return readMapPaint(FERRATA_TOKENS);
}
export function sameFerrataPaint(a: FerrataPaint, b: FerrataPaint): boolean {
return sameMapPaint(a, b);
}
@@ -0,0 +1,295 @@
/**
* FE-FERRATA-TILES-001 to FE-FERRATA-TILES-012: the per-trip tile store behind
* the via ferrata layer.
*
* Pinned: nothing goes out before the device cache was read, a tile is asked
* for once and never dropped, the view goes before any prefetch and runs
* beside one on its way rather than behind it, answers are
* kept per tile, the view's failures are reported and retried, and a stop
* calls off what is on its way but keeps what is loaded.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared';
import type { FerrataTileRow } from '../../repo/ferrataRepo';
import { FerrataTileStore, ferrataRowsOf } from './ferrataTileStore';
const ferrata = (id: number, lat: number, lng: number, lat2 = lat + 0.01, lng2 = lng): Ferrata => ({
id: `way/${id}`,
name: `Ferrata ${id}`,
grade: { raw: '3', level: 3, plus: null },
routeName: null,
geometry: [
[lat, lng],
[lat2, lng2],
],
osmUrl: `https://www.openstreetmap.org/way/${id}`,
});
const reply = (ferratas: Ferrata[], truncated = false): FerratasResponse => ({
ferratas,
truncated,
source: 'openstreetmap',
attribution: '© OpenStreetMap contributors',
});
interface Call {
bbox: FerrataBbox;
signal: AbortSignal;
resolve: (value: FerratasResponse) => void;
reject: (error: unknown) => void;
}
function harness(options: { persist?: boolean } = {}) {
const calls: Call[] = [];
const persisted: FerrataTileRow[][] = [];
const fetch = vi.fn(
(bbox: FerrataBbox, signal: AbortSignal) =>
new Promise<FerratasResponse>((resolve, reject) => calls.push({ bbox, signal, resolve, reject }))
);
const persist = vi.fn((rows: FerrataTileRow[]) => {
persisted.push(rows);
return Promise.resolve();
});
const store = new FerrataTileStore({
fetch,
persist: options.persist === false ? undefined : persist,
now: () => 1_000,
retryDelaysMs: [100, 200],
});
return { store, calls, fetch, persist, persisted };
}
/** Lets a settled fetch's handlers run. */
const flush = async () => {
await Promise.resolve();
await Promise.resolve();
};
const A = { row: 930, col: 236 }; // 46.50 to 46.55, 11.80 to 11.85
const B = { row: 930, col: 237 };
const FAR = { row: 940, col: 236 };
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('FerrataTileStore', () => {
it('FE-FERRATA-TILES-001: asks for nothing before the cache was read, and nothing the cache already had', () => {
const { store, fetch } = harness();
expect(store.beginHydration()).toBe(true);
expect(store.beginHydration()).toBe(false);
store.want([A, B], 'visible');
expect(fetch).not.toHaveBeenCalled();
expect(store.getSnapshot().waiting.size).toBe(2);
const cached = ferrata(1, 46.52, 11.82);
store.hydrate([
{ tile: '930:236', fetchedAt: 1, ferratas: [cached] },
{ tile: '930:237', fetchedAt: 1, ferratas: [] },
]);
expect(fetch).not.toHaveBeenCalled();
const snapshot = store.getSnapshot();
expect(snapshot.hydrated).toBe(true);
expect([...snapshot.loaded].sort()).toEqual(['930:236', '930:237']);
expect(snapshot.waiting.size).toBe(0);
expect(snapshot.ferratas).toEqual([cached]);
});
it('FE-FERRATA-TILES-002: asks for the missing tiles in one box, keeps them, and never asks for them again', async () => {
const { store, calls, fetch } = harness();
store.hydrate([]);
store.want([A, B], 'visible');
expect(fetch).toHaveBeenCalledTimes(1);
expect(calls[0]?.bbox).toEqual({ south: 46.5, west: 11.8, north: 46.55, east: 11.9 });
const found = ferrata(1, 46.52, 11.82);
calls[0]?.resolve(reply([found]));
await flush();
expect(store.getSnapshot().ferratas).toEqual([found]);
expect(store.getSnapshot().loaded.has('930:237')).toBe(true);
store.want([A, B], 'visible');
store.want([A], 'background');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('FE-FERRATA-TILES-003: keeps every ferrata it ever loaded, a later answer adding to them by id', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'visible');
calls[0]?.resolve(reply([ferrata(1, 46.52, 11.82)]));
await flush();
store.want([FAR], 'visible');
// The same way again, as a tile across its end returns it, and one more.
calls[1]?.resolve(reply([ferrata(1, 46.52, 11.82), ferrata(2, 47.01, 11.82)]));
await flush();
expect(store.getSnapshot().ferratas.map((f) => f.id)).toEqual(['way/1', 'way/2']);
});
it('FE-FERRATA-TILES-004: the view goes out beside a prefetch on its way, and prefetches go one at a time', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
store.want([FAR], 'background');
expect(calls).toHaveLength(1);
// The view does not wait for the prefetch already on its way.
store.want([B], 'visible');
expect(calls).toHaveLength(2);
expect(calls[1]?.bbox).toEqual({ south: 46.5, west: 11.85, north: 46.55, east: 11.9 });
calls[1]?.resolve(reply([]));
await flush();
expect(calls).toHaveLength(2);
calls[0]?.resolve(reply([]));
await flush();
expect(calls[2]?.bbox.south).toBe(47);
});
it('FE-FERRATA-TILES-012: runs two of the view requests at once and queues the rest behind them', async () => {
const { store, calls } = harness();
store.hydrate([]);
// Three blocks of the grid apart: three requests.
store.want([A, { row: 930, col: 240 }, { row: 930, col: 244 }], 'visible');
expect(calls).toHaveLength(2);
calls[0]?.resolve(reply([]));
await flush();
expect(calls).toHaveLength(3);
expect(calls[2]?.bbox.west).toBe(12.2);
});
it('FE-FERRATA-TILES-005: a tile a queued prefetch holds moves up with it when the view wants it', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
store.want([FAR], 'background');
store.want([B], 'background');
store.want([B], 'visible');
expect(store.getSnapshot().waiting.has('930:237')).toBe(true);
// Moved up, it takes the view's lane at once instead of waiting behind the other prefetches.
expect(calls).toHaveLength(2);
expect(calls[1]?.bbox.west).toBe(11.85);
});
it('FE-FERRATA-TILES-006: keeps each answer per tile, an empty tile included', async () => {
const { store, calls, persisted } = harness();
store.hydrate([]);
store.want([A, B], 'visible');
const inA = ferrata(1, 46.52, 11.82);
calls[0]?.resolve(reply([inA]));
await flush();
expect(persisted).toEqual([
[
{ tile: '930:236', fetchedAt: 1_000, ferratas: [inA] },
{ tile: '930:237', fetchedAt: 1_000, ferratas: [] },
],
]);
});
it('FE-FERRATA-TILES-007: reports the view failing and asks again after each delay; a 400 reads as zoom in', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'visible');
calls[0]?.reject(new Error('502'));
await flush();
expect(store.getSnapshot().failure).toBe('unavailable');
expect(store.getSnapshot().waiting.size).toBe(0);
await vi.advanceTimersByTimeAsync(100);
expect(calls).toHaveLength(2);
calls[1]?.resolve(reply([]));
await flush();
expect(store.getSnapshot().failure).toBeNull();
store.want([FAR], 'visible');
calls[2]?.reject({ response: { status: 400 } });
await flush();
expect(store.getSnapshot().failure).toBe('refused');
await vi.advanceTimersByTimeAsync(1_000);
expect(calls).toHaveLength(3);
});
it('FE-FERRATA-TILES-008: a failed prefetch says nothing and is asked for again on the next want', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
calls[0]?.reject(new Error('502'));
await flush();
expect(store.getSnapshot().failure).toBeNull();
store.want([A], 'background');
expect(calls).toHaveLength(2);
});
it('FE-FERRATA-TILES-009: a stop aborts what is on its way and drops the queue, and keeps what is loaded', async () => {
const { store, calls } = harness();
store.hydrate([{ tile: '940:236', fetchedAt: 1, ferratas: [ferrata(9, 47.01, 11.82)] }]);
store.want([A], 'visible');
store.want([B], 'background');
expect(calls).toHaveLength(2);
store.stop();
expect(calls.map((call) => call.signal.aborted)).toEqual([true, true]);
calls[0]?.resolve(reply([ferrata(1, 46.52, 11.82)]));
await flush();
expect(calls).toHaveLength(2);
expect(store.getSnapshot().ferratas.map((f) => f.id)).toEqual(['way/9']);
expect(store.getSnapshot().waiting.size).toBe(0);
// Wanted again, the tiles go out again.
store.want([A], 'visible');
expect(calls).toHaveLength(3);
});
it('FE-FERRATA-TILES-010: tells its listeners only of a change, and keeps the list while only the tiles change', async () => {
const { store, calls } = harness({ persist: false });
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);
store.hydrate([{ tile: '940:236', fetchedAt: 1, ferratas: [ferrata(9, 47.01, 11.82)] }]);
const first = store.getSnapshot();
expect(listener).toHaveBeenCalledTimes(1);
store.want([FAR], 'visible');
expect(listener).toHaveBeenCalledTimes(1);
expect(store.getSnapshot()).toBe(first);
store.want([A], 'visible');
calls[0]?.resolve(reply([]));
await flush();
expect(store.getSnapshot()).not.toBe(first);
expect(store.getSnapshot().ferratas).toBe(first.ferratas);
unsubscribe();
});
it('FE-FERRATA-TILES-011: a prefetch on its way that holds a tile of the view reports its failure as the view', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
store.want([A], 'visible');
expect(calls).toHaveLength(1);
expect(store.getSnapshot().waiting.has('930:236')).toBe(true);
calls[0]?.reject(new Error('502'));
await flush();
expect(store.getSnapshot().failure).toBe('unavailable');
expect(store.getSnapshot().waiting.size).toBe(0);
});
});
describe('ferrataRowsOf', () => {
it('gives a ferrata to every tile of the box its own line reaches into', () => {
// From 46.52 to 46.57: across the row line at 46.55.
const across = ferrata(1, 46.52, 11.82, 46.57, 11.82);
const rows = ferrataRowsOf({ minRow: 930, maxRow: 931, minCol: 236, maxCol: 237 }, [across], 5);
expect(rows.map((row) => [row.tile, row.ferratas.length])).toEqual([
['930:236', 1],
['930:237', 0],
['931:236', 1],
['931:237', 0],
]);
expect(rows.every((row) => row.fetchedAt === 5)).toBe(true);
});
});
@@ -0,0 +1,340 @@
import {
type Ferrata,
type FerrataBbox,
type FerratasResponse,
type FerrataTile,
type FerrataTileRange,
ferrataTileKey,
ferrataTileRange,
ferrataTileRequests,
ferrataTilesOf,
} from '@trek/shared';
import type { FerrataTileRow } from '../../repo/ferrataRepo';
import { ferrataPointsBbox } from './ferrataGroups';
import { ferrataPositions } from './ferrataModel';
/**
* The via ferrata layer's tiles for one trip, outside React.
*
* Every ferrata loaded for the trip stays, in a map by OSM id, whatever the
* map does afterwards: a pan or a zoom only asks for the tiles of the shared
* 0.05 degree grid that are not loaded yet, never for ones it has, and never
* drops any. Tiles wanted for the view on screen go first; the ring around it
* and the trip's area load behind them. The two have lanes of their own: the
* view's requests run beside a prefetch on its way rather than behind it,
* VISIBLE_AT_ONCE of them at a time, while prefetches go one at a time, so
* the view is never kept waiting by a prefetch. Answers go to `persist` per tile, and a
* trip's cached tiles come back through `hydrate`, which holds every request
* until the cache has been read, so a cached tile is never asked for again.
*
* Built for `useSyncExternalStore`: `subscribe` and `getSnapshot`, and the
* snapshot is a new object only when something in it changed.
*/
type FerrataTilePriority = 'visible' | 'background';
/**
* Why the last request for the view failed: `refused` (a 400, the server would
* not take the box, read as "zoom in") or `unavailable` (anything else: the
* Overpass mirrors, worth another try).
*/
type FerrataTileFailure = 'refused' | 'unavailable';
export interface FerrataTileSnapshot {
/** Every tile loaded for the trip, cached or fetched. */
loaded: ReadonlySet<string>;
/** Every ferrata those tiles hold, each once. */
ferratas: readonly Ferrata[];
/** Tiles wanted for the view that are queued or on their way. */
waiting: ReadonlySet<string>;
failure: FerrataTileFailure | null;
/** True when an answer for the view was cut at the server's cap. */
truncated: boolean;
/** Whether the cached tiles have been read. */
hydrated: boolean;
}
interface FerrataTileStoreDeps {
fetch: (bbox: FerrataBbox, signal: AbortSignal) => Promise<FerratasResponse>;
persist?: (rows: FerrataTileRow[]) => Promise<void>;
now?: () => number;
/** How long to wait before asking again for the view after the mirrors failed it. */
retryDelaysMs?: readonly number[];
}
interface Job {
bbox: FerrataBbox;
range: FerrataTileRange;
keys: string[];
priority: FerrataTilePriority;
}
const FERRATA_RETRY_DELAYS_MS = [5_000, 15_000, 45_000] as const;
/** Requests for the view on screen that may be on their way at once. */
const VISIBLE_AT_ONCE = 2;
/** Prefetches on their way at once: one, beside the view's own. */
const BACKGROUND_AT_ONCE = 1;
/** A job on its way, and the lane it took: the lane is what it counts against, even once it is moved up. */
interface Flight {
job: Job;
lane: FerrataTilePriority;
controller: AbortController;
}
function failureOf(error: unknown): FerrataTileFailure {
const status = (error as { response?: { status?: unknown } } | null)?.response?.status;
return status === 400 ? 'refused' : 'unavailable';
}
/**
* The ferratas of an answer per tile of the box it was for: each one goes to
* every tile its own box reaches into, and every tile gets a row, an empty one
* included, because "no ferrata here" is an answer worth keeping too.
*/
export function ferrataRowsOf(range: FerrataTileRange, ferratas: readonly Ferrata[], at: number): FerrataTileRow[] {
const rows = new Map(ferrataTilesOf(range).map((tile) => [ferrataTileKey(tile), [] as Ferrata[]]));
for (const ferrata of ferratas) {
const box = ferrataPointsBbox(ferrataPositions(ferrata));
if (!box) continue;
const own = ferrataTileRange(box);
const minRow = Math.max(own.minRow, range.minRow);
const maxRow = Math.min(own.maxRow, range.maxRow);
const minCol = Math.max(own.minCol, range.minCol);
const maxCol = Math.min(own.maxCol, range.maxCol);
if (minRow > maxRow || minCol > maxCol) continue;
for (const tile of ferrataTilesOf({ minRow, maxRow, minCol, maxCol }))
rows.get(ferrataTileKey(tile))?.push(ferrata);
}
return [...rows].map(([tile, list]) => ({ tile, fetchedAt: at, ferratas: list }));
}
export class FerrataTileStore {
private readonly loaded = new Set<string>();
private readonly byId = new Map<string, Ferrata>();
/** Tiles in a queued or in-flight job, so no tile is asked for twice at once. */
private readonly pending = new Set<string>();
private readonly waiting = new Set<string>();
private queue: Job[] = [];
private readonly inFlight = new Set<Flight>();
private failure: FerrataTileFailure | null = null;
private truncated = false;
private hydrated = false;
private hydrating = false;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private retryAttempt = 0;
private readonly listeners = new Set<() => void>();
private snapshot: FerrataTileSnapshot;
/** Writes to the device cache that failed; the tiles still draw, they only load again next time. */
persistFailures = 0;
constructor(private readonly deps: FerrataTileStoreDeps) {
this.snapshot = this.build();
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = (): FerrataTileSnapshot => this.snapshot;
/** Starts reading the cache; requests wait until `hydrate` hands its rows over. */
beginHydration(): boolean {
if (this.hydrated || this.hydrating) return false;
this.hydrating = true;
return true;
}
/** The cached tiles. Queued jobs whose tiles all came back from the cache are dropped. */
hydrate(rows: readonly FerrataTileRow[]): void {
for (const row of rows) {
this.loaded.add(row.tile);
this.waiting.delete(row.tile);
for (const ferrata of row.ferratas) this.byId.set(ferrata.id, ferrata);
}
this.hydrating = false;
this.hydrated = true;
this.queue = this.queue.filter((job) => {
if (!job.keys.every((key) => this.loaded.has(key))) return true;
for (const key of job.keys) this.pending.delete(key);
return false;
});
this.emit();
this.pump();
}
/**
* Asks for the tiles not loaded or on their way yet. `visible` ones jump the
* queue, and a background job already holding one of them is moved up with
* it; the error of an earlier view is cleared, its retry called off.
*/
want(tiles: readonly FerrataTile[], priority: FerrataTilePriority): void {
const keys = tiles.map(ferrataTileKey);
if (priority === 'visible') {
this.clearRetry();
// The ones on their way too, so their failure is reported and retried like the view's own.
const jobs = [...[...this.inFlight].map((flight) => flight.job), ...this.queue];
for (const job of jobs) {
if (job.priority === 'background' && job.keys.some((key) => keys.includes(key))) job.priority = 'visible';
}
for (const key of keys) if (!this.loaded.has(key)) this.waiting.add(key);
}
const missing = tiles.filter((tile, index) => !this.loaded.has(keys[index]) && !this.pending.has(keys[index]));
for (const request of ferrataTileRequests(missing)) {
const jobKeys = ferrataTilesOf(request.range).map(ferrataTileKey);
for (const key of jobKeys) this.pending.add(key);
this.queue.push({ ...request, keys: jobKeys, priority });
}
this.emit();
this.pump();
}
/** Calls off everything queued, in flight or due for a retry; what is loaded stays. */
stop(): void {
this.clearRetry();
for (const flight of this.inFlight) flight.controller.abort();
this.inFlight.clear();
this.queue = [];
this.pending.clear();
this.waiting.clear();
this.emit();
}
private clearRetry(): void {
if (this.retryTimer !== null) clearTimeout(this.retryTimer);
this.retryTimer = null;
}
/** How many jobs of a lane are on their way. */
private flying(lane: FerrataTilePriority): number {
let count = 0;
for (const flight of this.inFlight) if (flight.lane === lane) count += 1;
return count;
}
/** The next queued job of a priority, taken off the queue, or null for none. */
private take(priority: FerrataTilePriority): Job | null {
const index = this.queue.findIndex((job) => job.priority === priority);
if (index < 0) return null;
const [job] = this.queue.splice(index, 1);
return job ?? null;
}
/** Starts what the lanes have room for: the view's jobs in theirs, the prefetches in theirs. */
private pump(): void {
if (!this.hydrated) return;
for (const [lane, room] of [
['visible', VISIBLE_AT_ONCE],
['background', BACKGROUND_AT_ONCE],
] as const) {
while (this.flying(lane) < room) {
const job = this.take(lane);
if (!job) break;
this.launch(job, lane);
}
}
}
private launch(job: Job, lane: FerrataTilePriority): void {
const controller = new AbortController();
const flight: Flight = { job, lane, controller };
this.inFlight.add(flight);
void this.deps.fetch(job.bbox, controller.signal).then(
(reply) => {
if (controller.signal.aborted) return;
this.inFlight.delete(flight);
this.settle(job, reply);
},
(error: unknown) => {
if (controller.signal.aborted) return;
this.inFlight.delete(flight);
this.fail(job, failureOf(error));
}
);
}
private settle(job: Job, reply: FerratasResponse): void {
const at = (this.deps.now ?? Date.now)();
for (const ferrata of reply.ferratas) this.byId.set(ferrata.id, ferrata);
for (const key of job.keys) {
this.loaded.add(key);
this.pending.delete(key);
this.waiting.delete(key);
}
if (job.priority === 'visible') {
this.failure = null;
this.retryAttempt = 0;
this.truncated = reply.truncated;
}
const persist = this.deps.persist;
if (persist) {
void persist(ferrataRowsOf(job.range, reply.ferratas, at)).catch(() => {
this.persistFailures += 1;
});
}
this.emit();
this.pump();
}
private fail(job: Job, failure: FerrataTileFailure): void {
for (const key of job.keys) this.pending.delete(key);
if (job.priority === 'visible') {
// The view's tiles are no longer on their way: the status reads the failure instead.
for (const key of job.keys) this.waiting.delete(key);
this.failure = failure;
const delay = (this.deps.retryDelaysMs ?? FERRATA_RETRY_DELAYS_MS)[this.retryAttempt];
if (failure === 'unavailable' && delay !== undefined) {
this.retryAttempt += 1;
const tiles = ferrataTilesOf(job.range);
this.clearRetry();
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.want(tiles, 'visible');
}, delay);
}
}
this.emit();
this.pump();
}
private build(): FerrataTileSnapshot {
return {
loaded: new Set(this.loaded),
ferratas: [...this.byId.values()],
waiting: new Set(this.waiting),
failure: this.failure,
truncated: this.truncated,
hydrated: this.hydrated,
};
}
private emit(): void {
const next = this.build();
const prev = this.snapshot;
// An unchanged list keeps its reference, so what is drawn from it is not rebuilt.
const sameFerratas =
prev.ferratas.length === next.ferratas.length &&
prev.ferratas.every((ferrata, index) => ferrata === next.ferratas[index]);
const same =
sameFerratas &&
prev.failure === next.failure &&
prev.truncated === next.truncated &&
prev.hydrated === next.hydrated &&
sameSet(prev.loaded, next.loaded) &&
sameSet(prev.waiting, next.waiting);
if (same) return;
this.snapshot = sameFerratas ? { ...next, ferratas: prev.ferratas } : next;
for (const listener of [...this.listeners]) listener();
}
}
function sameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a.size !== b.size) return false;
for (const value of a) if (!b.has(value)) return false;
return true;
}
@@ -0,0 +1,333 @@
/**
* FE-FERRATA-GL-001 to FE-FERRATA-GL-013: the via ferrata layer on a
* MapLibre or Mapbox GL map.
*
* Pinned: the layers go in once per map (and wait for `idle` when tiles are
* still loading, the failure the hazard overlay had), the line is the dark
* dashed via ferrata violet and never a grade colour, new data replaces the
* sources' contents instead of rebuilding the layers, a click on the hit line
* hands the ferrata and the way to `onSelect`, hover and selection highlight
* the whole ferrata (solid, on a wider casing) and name it above the pointer,
* the 3D terrain draws wider lines, the route icon joins once its image is drawn, a theme
* change repaints the installed layers in place, and an unmount leaves nothing
* behind.
*/
import { act, render, renderHook, waitFor } from '@testing-library/react';
import type { Map as MapboxMap, MapMouseEvent } from 'mapbox-gl';
import { describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import FerrataLayerGL from './FerrataLayerGL';
import { groupFerratas } from './ferrataGroups';
import { useFerrataLayerGL } from './useFerrataLayerGL';
vi.mock('./ferrataIcon', async (importOriginal) => ({
...(await importOriginal<typeof import('./ferrataIcon')>()),
loadFerrataIcon: vi.fn(() => Promise.resolve({ width: 44, height: 44 })),
}));
const SOURCE = 'trek-ferratas';
const LABEL_SOURCE = 'trek-ferratas-labels';
const CASING = 'trek-ferratas-casing';
const LINE = 'trek-ferratas-line';
const HIGHLIGHT_CASING = 'trek-ferratas-highlight-casing';
const HIGHLIGHT = 'trek-ferratas-highlight';
const HIT = 'trek-ferratas-hit';
const LABEL = 'trek-ferratas-label';
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT];
const ferrata = (id: string, lat: number, name: string, lng = 11.7): Ferrata => ({
id,
name,
grade: { raw: '2', level: 2, plus: null },
routeName: null,
geometry: [
[lat, lng],
[lat + 0.01, lng + 0.01],
],
osmUrl: `https://www.openstreetmap.org/${id}`,
});
/** Two ferratas: way/1 and way/2 are one (same name, touching), way/3 another. */
const GROUPS = groupFerratas([
ferrata('way/1', 46.5, 'Via A'),
ferrata('way/2', 46.51, 'Via A', 11.71),
ferrata('way/3', 47, 'Via B'),
]);
type Handler = (event?: unknown) => void;
function fakeGlMap(styleLoaded = true) {
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
const layers = new Set<string>();
const images = new Set<string>();
const handlers = new Map<string, Set<Handler>>();
const once = new Map<string, Set<Handler>>();
const state = { styleLoaded, removed: false, hit: null as null | { properties: { id: string; group: string } } };
const canvas = document.createElement('canvas');
const container = document.createElement('div');
const keyOf = (type: string, layer?: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type);
const map = {
isStyleLoaded: vi.fn(() => state.styleLoaded),
getSource: vi.fn((id: string) => sources.get(id)),
addSource: vi.fn((id: string) => {
sources.set(id, { setData: vi.fn() });
}),
removeSource: vi.fn((id: string) => {
sources.delete(id);
}),
// Like MapLibre: a removed map has no style, and asking it for a layer throws.
getStyle: vi.fn(() => (state.removed ? undefined : { version: 8 })),
getLayer: vi.fn((id: string) => {
if (state.removed) throw new TypeError('can\'t access property "getLayer", this.style is undefined');
return layers.has(id) ? {} : undefined;
}),
addLayer: vi.fn((spec: { id: string }) => {
layers.add(spec.id);
}),
removeLayer: vi.fn((id: string) => {
layers.delete(id);
}),
setPaintProperty: vi.fn(),
setFilter: vi.fn(),
hasImage: vi.fn((id: string) => images.has(id)),
addImage: vi.fn((id: string) => {
images.add(id);
}),
removeImage: vi.fn((id: string) => {
images.delete(id);
}),
queryRenderedFeatures: vi.fn(() => (state.hit ? [state.hit] : [])),
getCanvas: vi.fn(() => canvas),
getContainer: vi.fn(() => container),
on: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
const key = keyOf(type, layerOrFn);
const set = handlers.get(key) ?? new Set<Handler>();
set.add((fn ?? layerOrFn) as Handler);
handlers.set(key, set);
}),
once: vi.fn((type: string, fn: Handler) => {
const set = once.get(type) ?? new Set<Handler>();
set.add(fn);
once.set(type, set);
}),
off: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
const target = (fn ?? layerOrFn) as Handler;
handlers.get(keyOf(type, layerOrFn))?.delete(target);
once.get(type)?.delete(target);
}),
};
const emit = (key: string, event?: unknown) => [...(handlers.get(key) ?? [])].forEach((fn) => fn(event));
return {
map: map as unknown as MapboxMap,
spies: map,
state,
sources,
layers,
images,
canvas,
container,
emit,
listeners: () => [...handlers.values()].reduce((sum, set) => sum + set.size, 0),
emitIdle: () => {
const pending = [...(once.get('idle') ?? [])];
once.delete('idle');
pending.forEach((fn) => fn());
},
};
}
const click = { point: { x: 4, y: 4 }, lngLat: { lng: 11.705, lat: 46.505 } } as unknown as MapMouseEvent;
const addedIds = (gl: ReturnType<typeof fakeGlMap>) => gl.spies.addLayer.mock.calls.map(([layer]) => layer.id);
const spec = (gl: ReturnType<typeof fakeGlMap>, id: string) =>
gl.spies.addLayer.mock.calls.map(([layer]) => layer).find((layer) => layer.id === id) as unknown as {
paint: Record<string, unknown>;
filter?: unknown;
minzoom?: number;
layout?: Record<string, unknown>;
};
const filterOf = (ids: string[]) => ['in', ['get', 'group'], ['literal', ids]];
describe('useFerrataLayerGL', () => {
it('FE-FERRATA-GL-001: adds the line and label sources and the casing, line, highlight casing, highlight and hit layers, in that order', () => {
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }));
expect(gl.spies.addSource).toHaveBeenCalledWith(LABEL_SOURCE, expect.objectContaining({ type: 'geojson' }));
expect(addedIds(gl)).toEqual(LAYERS);
});
it('FE-FERRATA-GL-002: draws a dashed violet line on a white casing, never by grade', () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata', 'black');
root.style.setProperty('--map-ferrata-casing', 'white');
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
const line = spec(gl, LINE);
expect(line.paint['line-color']).toBe('black');
expect(line.paint['line-dasharray']).toEqual([2, 1.5]);
expect(spec(gl, CASING).paint['line-color']).toBe('white');
root.style.removeProperty('--map-ferrata');
root.style.removeProperty('--map-ferrata-casing');
});
it('FE-FERRATA-GL-003: waits for idle when the style is still loading tiles', () => {
const gl = fakeGlMap(false);
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(gl.spies.addSource).not.toHaveBeenCalled();
gl.state.styleLoaded = true;
gl.emitIdle();
expect(addedIds(gl)).toEqual(LAYERS);
});
it('FE-FERRATA-GL-004: new ferratas replace the sources data without rebuilding the layers', () => {
const gl = fakeGlMap();
const { rerender } = renderHook(({ groups }) => useFerrataLayerGL(gl.map, groups, vi.fn()), {
initialProps: { groups: GROUPS.slice(0, 1) },
});
rerender({ groups: GROUPS });
expect(addedIds(gl)).toEqual(LAYERS);
expect(gl.sources.get(SOURCE)?.setData).toHaveBeenLastCalledWith(
expect.objectContaining({
features: [
expect.objectContaining({ properties: { id: 'way/1', group: 'group/way/1' } }),
expect.objectContaining({ properties: { id: 'way/2', group: 'group/way/1' } }),
expect.objectContaining({ properties: { id: 'way/3', group: 'group/way/3' } }),
],
})
);
expect(gl.sources.get(LABEL_SOURCE)?.setData).toHaveBeenLastCalledWith(
expect.objectContaining({ features: [expect.anything(), expect.anything()] })
);
});
it('FE-FERRATA-GL-005: a click on the hit line hands the ferrata, the way and the point to onSelect', () => {
const gl = fakeGlMap();
const onSelect = vi.fn();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, onSelect));
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
gl.emit('click', click);
expect(onSelect).toHaveBeenCalledWith(GROUPS[0], GROUPS[0]?.segments[1], { lng: 11.705, lat: 46.505 });
onSelect.mockClear();
gl.state.hit = null;
gl.emit('click', click);
expect(onSelect).not.toHaveBeenCalled();
});
it('FE-FERRATA-GL-006: the pointer turns into a hand over a line, and the whole ferrata under it lights up', () => {
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(spec(gl, HIGHLIGHT).filter).toEqual(filterOf([]));
gl.emit(`mouseenter:${HIT}`);
expect(gl.canvas.style.cursor).toBe('pointer');
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
act(() => gl.emit(`mousemove:${HIT}`, click));
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf(['group/way/1']));
act(() => gl.emit(`mouseleave:${HIT}`));
expect(gl.canvas.style.cursor).toBe('');
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf([]));
});
it('FE-FERRATA-GL-007: the ferrata open in the card stays highlighted', () => {
const gl = fakeGlMap();
const { rerender } = renderHook(({ selected }) => useFerrataLayerGL(gl.map, GROUPS, vi.fn(), selected), {
initialProps: { selected: null as string | null },
});
rerender({ selected: 'group/way/3' });
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf(['group/way/3']));
});
it('FE-FERRATA-GL-008: adds the route icon layer from the zoom it shows at, once its image is drawn', async () => {
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
expect(gl.images.has('trek-ferrata-icon')).toBe(true);
const label = spec(gl, LABEL);
expect(label.minzoom).toBe(13);
expect(label.layout?.['icon-image']).toBe('trek-ferrata-icon');
});
it('FE-FERRATA-GL-009: unmounting removes the layers, the sources, the icon and every listener; no map, no work', async () => {
const gl = fakeGlMap();
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
unmount();
expect(gl.layers.size).toBe(0);
expect(gl.sources.size).toBe(0);
expect(gl.images.size).toBe(0);
expect(gl.listeners()).toBe(0);
expect(() => renderHook(() => useFerrataLayerGL(null, GROUPS, vi.fn()))).not.toThrow();
});
it('FE-FERRATA-GL-013: a map already removed with its view (leaving 3D) is left alone on unmount', async () => {
const gl = fakeGlMap();
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
gl.state.removed = true;
expect(() => unmount()).not.toThrow();
expect(gl.listeners()).toBe(0);
});
it('FE-FERRATA-GL-010: a theme change repaints the installed layers without rebuilding them', async () => {
const root = document.documentElement;
root.style.setProperty('--map-ferrata', 'black');
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(gl.spies.setPaintProperty).not.toHaveBeenCalled();
await act(async () => {
root.style.setProperty('--map-ferrata', 'ivory');
await Promise.resolve();
});
await waitFor(() => expect(gl.spies.setPaintProperty).toHaveBeenCalledWith(LINE, 'line-color', 'ivory'));
expect(addedIds(gl).filter((id) => LAYERS.includes(id))).toEqual(LAYERS);
root.style.removeProperty('--map-ferrata');
});
it('FE-FERRATA-GL-011: names the ferrata under the pointer with its grade, above the pointer, and hides it on leave', () => {
const gl = fakeGlMap();
const { unmount } = renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
act(() => gl.emit(`mousemove:${HIT}`, click));
const label = gl.container.querySelector<HTMLElement>('[data-testid="ferrata-hover-label"]');
expect(label?.style.display).toBe('');
expect(label?.textContent).toBe('Via AK2');
expect(label?.querySelector('span')?.textContent).toBe('K2');
expect(label?.style.transform).toContain('translate(4px, -8px)');
expect(label).toHaveClass('bg-surface-card', 'text-content', 'border-edge-faint');
act(() => gl.emit(`mouseleave:${HIT}`));
expect(label?.style.display).toBe('none');
unmount();
expect(gl.container.querySelector('[data-testid="ferrata-hover-label"]')).toBeNull();
});
it('FE-FERRATA-GL-012: lights a ferrata solid on a wider casing, and draws wider lines on the 3D terrain', () => {
const flat = fakeGlMap();
renderHook(() => useFerrataLayerGL(flat.map, GROUPS, vi.fn()));
expect(spec(flat, CASING).paint['line-width']).toBe(6);
expect(spec(flat, LINE).paint['line-width']).toBe(2.5);
expect(spec(flat, HIGHLIGHT_CASING).paint['line-width']).toBe(9);
expect(spec(flat, HIGHLIGHT).paint['line-width']).toBe(4);
expect(spec(flat, HIGHLIGHT).paint['line-dasharray']).toBeUndefined();
const terrain = fakeGlMap();
renderHook(() => useFerrataLayerGL(terrain.map, GROUPS, vi.fn(), null, 'terrain'));
expect(spec(terrain, CASING).paint['line-width']).toBe(8);
expect(spec(terrain, LINE).paint['line-width']).toBe(3.5);
});
});
@@ -0,0 +1,332 @@
import type { ExpressionSpecification, GeoJSONSource, Map, MapMouseEvent } from 'mapbox-gl';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Ferrata } from '@trek/shared';
import { useTranslation } from '../../i18n';
import { mapHoverLabel } from '../Map/mapHoverLabel';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { FERRATA_ICON_IMAGE, FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_PIXEL_RATIO, loadFerrataIcon } from './ferrataIcon';
import { ferrataFeatureCollection, ferrataLabelCollection } from './ferrataModel';
import type { FerrataPaint } from './ferrataPaint';
import { useFerrataPaint } from './useFerrataPaint';
const SOURCE = 'trek-ferratas';
const LABEL_SOURCE = `${SOURCE}-labels`;
const CASING = `${SOURCE}-casing`;
const LINE = `${SOURCE}-line`;
/** The wider casing under a lit ferrata, so it stands out by its shape and not only its colour. */
const HIGHLIGHT_CASING = `${SOURCE}-highlight-casing`;
/** The whole ferrata under the pointer or open in the card, drawn solid over its own line. */
const HIGHLIGHT = `${SOURCE}-highlight`;
/** A wide, invisible line on top, so a thin dashed route is still easy to tap. */
const HIT = `${SOURCE}-hit`;
const LABEL = `${SOURCE}-label`;
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT, LABEL];
const LIT_LAYERS = [HIGHLIGHT_CASING, HIGHLIGHT];
/**
* The line widths, in pixels. On the 3D terrain the map is seen at a slant
* and over a hillshade, so the lines there are drawn wider to stay readable.
*/
interface FerrataWidths {
casing: number;
line: number;
litCasing: number;
lit: number;
}
const FLAT_WIDTHS: FerrataWidths = { casing: 6, line: 2.5, litCasing: 9, lit: 4 };
const TERRAIN_WIDTHS: FerrataWidths = { casing: 8, line: 3.5, litCasing: 11, lit: 5 };
/** Where the layer is drawn: the flat map, or the 3D terrain, which draws its lines wider. */
type FerrataSurface = 'flat' | 'terrain';
/** Where on the map a ferrata was clicked, for a popup or card anchored there. */
interface FerrataClickPoint {
lng: number;
lat: number;
}
/** A click on a line: the ferrata it belongs to, the OSM way under the pointer and where it landed. */
export type FerrataSelectHandler = (group: FerrataGroup, segment: Ferrata, at: FerrataClickPoint) => void;
/** The groups to highlight, as a GL filter on the features' `group`. */
function highlightFilter(ids: readonly string[]): ExpressionSpecification {
return ['in', ['get', 'group'], ['literal', [...ids]]];
}
const LINE_LAYOUT = { 'line-cap': 'round', 'line-join': 'round' } as const;
/**
* The two sources and the five line layers, bottom to top: casing, dashed
* line, the lit ferrata's wider casing and its solid line, hit line.
*/
function addFerrataLayers(
map: Map,
paint: FerrataPaint,
widths: FerrataWidths,
data: GeoJSON.GeoJSON,
labels: GeoJSON.GeoJSON
): void {
map.addSource(SOURCE, { type: 'geojson', data });
map.addSource(LABEL_SOURCE, { type: 'geojson', data: labels });
map.addLayer({
id: CASING,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
paint: { 'line-color': paint.casing, 'line-width': widths.casing, 'line-opacity': 0.9 },
});
map.addLayer({
id: LINE,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
paint: { 'line-color': paint.line, 'line-width': widths.line, 'line-dasharray': [2, 1.5] },
});
map.addLayer({
id: HIGHLIGHT_CASING,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
filter: highlightFilter([]),
paint: { 'line-color': paint.casing, 'line-width': widths.litCasing },
});
map.addLayer({
id: HIGHLIGHT,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
filter: highlightFilter([]),
paint: { 'line-color': paint.highlight, 'line-width': widths.lit },
});
map.addLayer({
id: HIT,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
paint: { 'line-color': paint.casing, 'line-width': 16, 'line-opacity': 0 },
});
}
/**
* Draws the route icon in `paint` and, the first time, the symbol layer that
* shows it from FERRATA_ICON_MIN_ZOOM. Done once the image is decoded; a
* layer torn down meanwhile (`gone`) is left alone.
*/
function addFerrataIcon(map: Map, paint: FerrataPaint, gone: () => boolean): void {
void loadFerrataIcon(paint).then(
(image) => {
if (gone() || !map.getSource(LABEL_SOURCE)) return;
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
map.addImage(FERRATA_ICON_IMAGE, image, { pixelRatio: FERRATA_ICON_PIXEL_RATIO });
if (map.getLayer(LABEL)) return;
map.addLayer({
id: LABEL,
type: 'symbol',
source: LABEL_SOURCE,
minzoom: FERRATA_ICON_MIN_ZOOM,
layout: { 'icon-image': FERRATA_ICON_IMAGE, 'icon-allow-overlap': false, 'icon-ignore-placement': false },
});
},
// Undecodable: the lines simply go without their icons.
() => undefined
);
}
function removeFerrataLayers(map: Map): void {
// A map already removed with its view (leaving 3D) took its style and the layers with it;
// asking it for a layer then throws.
if (!map.getStyle?.()) return;
for (const layer of [...LAYERS].reverse()) if (map.getLayer(layer)) map.removeLayer(layer);
for (const source of [LABEL_SOURCE, SOURCE]) if (map.getSource(source)) map.removeSource(source);
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
}
/**
* The via ferrata layer on a MapLibre or Mapbox GL map: a source of the OSM
* ways and one of a point per ferrata, a light casing, the dark dashed line,
* the highlight of the ferrata under the pointer or `selectedId` (solid and
* wider, on a wider casing), a hit line that turns a click into
* `onSelect(group, way, point)` and shows the name and grade above the
* pointer, and from FERRATA_ICON_MIN_ZOOM a small route icon halfway along
* each ferrata. `surface` 'terrain' draws the lines wider for the 3D map.
*
* The layers are installed once per map and again after a style swap (a
* basemap or theme change drops every custom layer); new ferratas only replace
* the sources' data, so tiles loading after a pan do not rebuild the layers.
* Like the hazard overlay, a draw that finds the style busy loading tiles
* waits for `idle` rather than for a `style.load` that may never come. A theme
* change repaints the installed layers in place and redraws the icon.
*/
export function useFerrataLayerGL(
map: Map | null,
groups: readonly FerrataGroup[],
onSelect: FerrataSelectHandler,
selectedId: string | null = null,
surface: FerrataSurface = 'flat'
) {
const { t } = useTranslation();
const data = useMemo(() => ferrataFeatureCollection(groups), [groups]);
const labels = useMemo(() => ferrataLabelCollection(groups), [groups]);
const paint = useFerrataPaint();
const [hovered, setHovered] = useState<string | null>(null);
// The handlers read the newest props without reinstalling the layers on every render.
const latest = useRef({ groups, data, labels, onSelect, paint, t, surface });
// The paint the installed layers were last drawn in, so a mount does not repaint what it just drew.
const painted = useRef<FerrataPaint | null>(null);
// Set by the install effect: redraws the icon in the current paint on a theme change.
const iconRepaint = useRef<(() => void) | null>(null);
useEffect(() => {
latest.current = { groups, data, labels, onSelect, paint, t, surface };
});
useEffect(() => {
if (!map) return;
let waiting = false;
let disposed = false;
const hoverLabel = mapHoverLabel(map, 'ferrata-hover-label');
const draw = () => {
if (map.getSource(SOURCE)) return;
if (!map.isStyleLoaded()) {
if (!waiting) {
waiting = true;
map.once('idle', retry);
}
return;
}
const { paint, data, labels } = latest.current;
painted.current = paint;
const widths = latest.current.surface === 'terrain' ? TERRAIN_WIDTHS : FLAT_WIDTHS;
addFerrataLayers(map, paint, widths, data, labels);
addFerrataIcon(map, paint, () => disposed);
};
const retry = () => {
waiting = false;
draw();
};
const featureAt = (event: MapMouseEvent) => {
if (!map.getLayer(HIT)) return null;
const hit = map.queryRenderedFeatures(event.point, { layers: [HIT] })[0];
return hit?.properties ?? null;
};
const click = (event: MapMouseEvent) => {
const id: unknown = featureAt(event)?.id;
for (const group of latest.current.groups) {
const segment = group.segments.find((candidate) => candidate.id === id);
if (segment) {
latest.current.onSelect(group, segment, { lng: event.lngLat.lng, lat: event.lngLat.lat });
return;
}
}
};
const move = (event: MapMouseEvent) => {
const id: unknown = featureAt(event)?.group;
const group = typeof id === 'string' ? latest.current.groups.find((candidate) => candidate.id === id) : undefined;
setHovered(group ? group.id : null);
if (group) {
const label = ferrataLabel(group, latest.current.t);
hoverLabel.show({ name: label.name, detail: label.grade }, event.point);
} else hoverLabel.hide();
};
const enter = () => {
map.getCanvas().style.cursor = 'pointer';
};
const leave = () => {
map.getCanvas().style.cursor = '';
setHovered(null);
hoverLabel.hide();
};
const repaintIcon = () => addFerrataIcon(map, latest.current.paint, () => disposed);
draw();
map.on('style.load', draw);
map.on('click', click);
map.on('mousemove', HIT, move);
map.on('mouseenter', HIT, enter);
map.on('mouseleave', HIT, leave);
iconRepaint.current = repaintIcon;
return () => {
disposed = true;
iconRepaint.current = null;
map.off('style.load', draw);
map.off('idle', retry);
map.off('click', click);
map.off('mousemove', HIT, move);
map.off('mouseenter', HIT, enter);
map.off('mouseleave', HIT, leave);
hoverLabel.remove();
removeFerrataLayers(map);
};
}, [map]);
useEffect(() => {
(map?.getSource(SOURCE) as GeoJSONSource | undefined)?.setData(data);
(map?.getSource(LABEL_SOURCE) as GeoJSONSource | undefined)?.setData(labels);
}, [map, data, labels]);
const highlighted = useMemo(
() => [...new Set([hovered, selectedId].filter((id): id is string => !!id))],
[hovered, selectedId]
);
useEffect(() => {
for (const layer of LIT_LAYERS) if (map?.getLayer(layer)) map.setFilter(layer, highlightFilter(highlighted));
}, [map, highlighted]);
useEffect(() => {
if (!map || painted.current === paint || !map.getLayer(LINE)) return;
painted.current = paint;
for (const layer of [CASING, HIGHLIGHT_CASING])
if (map.getLayer(layer)) map.setPaintProperty(layer, 'line-color', paint.casing);
map.setPaintProperty(LINE, 'line-color', paint.line);
if (map.getLayer(HIGHLIGHT)) map.setPaintProperty(HIGHLIGHT, 'line-color', paint.highlight);
iconRepaint.current?.();
}, [map, paint]);
}
/** The GL map the layer draws on. MapLibre's map answers the same calls the layer makes. */
export type FerrataGlMap = Map;
/**
* Just the two calls a hit test needs, so the Mapbox and the MapLibre map both
* fit, each with the point type of its own click events. Optional because a stand-in map (a test double, a map torn down mid
* click) may lack them, and a hit test must then answer no rather than throw.
*/
interface HitTestMap<Point> {
getLayer?(id: string): unknown;
queryRenderedFeatures?(point: Point, options: { layers: string[] }): unknown[];
}
/**
* Whether a click at `point` landed on a drawn ferrata. A map's own click
* handler asks this first, so picking a ferrata opens its card instead of also
* dropping a control point or a place under it.
*/
export function isFerrataHit<Point>(map: HitTestMap<Point>, point: Point): boolean {
if (typeof map.getLayer !== 'function' || typeof map.queryRenderedFeatures !== 'function') return false;
return !!map.getLayer(HIT) && map.queryRenderedFeatures(point, { layers: [HIT] }).length > 0;
}
const NO_GROUPS: readonly FerrataGroup[] = [];
const IGNORE_SELECT: FerrataSelectHandler = () => {};
/**
* The layer as a map view mounts it from a `FerrataMapLayer`: drawn only while
* it is switched on, so a map with the layer off carries no source or layers.
*/
export function useFerrataMapLayerGL(
map: Map | null,
layer:
| { active: boolean; groups: readonly FerrataGroup[]; onSelect: FerrataSelectHandler; selectedId: string | null }
| undefined,
surface: FerrataSurface = 'flat'
) {
useFerrataLayerGL(
layer?.active ? map : null,
layer?.groups ?? NO_GROUPS,
layer?.onSelect ?? IGNORE_SELECT,
layer?.selectedId ?? null,
surface
);
}
@@ -0,0 +1,150 @@
/**
* FE-FERRATA-MAPLAYER-001 to FE-FERRATA-MAPLAYER-008: the via ferrata layer as
* one map surface holds it: off by default, the area the map reports, the
* trip's area to prefetch, the ferratas grouped from OSM's segments with their
* count in view, and the card a click opens.
*
* useTripFerratas is mocked: what it asks the server is its own suite's
* subject. This one checks what the layer hands it (the view, whether it is on
* at all, the trip's area) and what it hands the map back.
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata, FerrataBbox } from '@trek/shared';
import { ferrataAreaOf, useFerrataMapLayer } from './useFerrataMapLayer';
import { useTripFerratas } from './useTripFerratas';
vi.mock('./useTripFerratas', () => ({ useTripFerratas: vi.fn() }));
const BOX: FerrataBbox = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 };
const OTHER: FerrataBbox = { south: 47.4, west: 11.6, north: 47.5, east: 11.8 };
const segment = (id: number, lat: number): Ferrata => ({
id: `way/${id}`,
name: 'Via delle Trincee',
grade: { raw: String(id), level: id, plus: null },
routeName: null,
geometry: [
[lat, 11.8],
[lat + 0.001, 11.8],
],
osmUrl: `https://www.openstreetmap.org/way/${id}`,
});
// Two touching segments of one ferrata.
const LOWER = segment(2, 46.52);
const UPPER = segment(4, 46.521);
const lastCall = () => vi.mocked(useTripFerratas).mock.lastCall;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useTripFerratas).mockReturnValue({ ferratas: [LOWER, UPPER], status: 'ready', truncated: false });
});
describe('useFerrataMapLayer', () => {
it('FE-FERRATA-MAPLAYER-001: starts off, so nothing is asked for', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
expect(result.current.layer.active).toBe(false);
expect(result.current.layer.count).toBe(0);
expect(result.current.selected).toBeNull();
expect(lastCall()).toEqual([7, null, false, null, []]);
});
it('FE-FERRATA-MAPLAYER-002: an area reported while off is remembered without a re-render, and used once it is on', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
const renders = vi.mocked(useTripFerratas).mock.calls.length;
act(() => result.current.onViewportChange(BOX));
expect(vi.mocked(useTripFerratas).mock.calls.length).toBe(renders);
act(() => result.current.layer.onToggle());
expect(result.current.layer.active).toBe(true);
expect(lastCall()?.slice(0, 3)).toEqual([7, BOX, true]);
});
it('FE-FERRATA-MAPLAYER-003: while on, every new area reaches the request', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
act(() => result.current.onViewportChange(OTHER));
expect(lastCall()?.slice(0, 3)).toEqual([7, OTHER, true]);
});
it("FE-FERRATA-MAPLAYER-004: hands the trip's area around its lines to the loader", () => {
const lines = [
[[46.5, 11.7]],
[
[46.7, 11.6],
[46.6, 11.9],
],
] as Array<Array<[number, number]>>;
renderHook(() => useFerrataMapLayer(7, lines));
expect(lastCall()?.[3]).toEqual({ south: 46.5, west: 11.6, north: 46.7, east: 11.9 });
expect(ferrataAreaOf([])).toBeNull();
expect(ferrataAreaOf([[[Number.NaN, 1]]])).toBeNull();
});
it('FE-FERRATA-MAPLAYER-005: groups the segments into one ferrata and counts the ferratas in view', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.onViewportChange(BOX));
act(() => result.current.layer.onToggle());
expect(result.current.layer.groups).toHaveLength(1);
expect(result.current.layer.groups[0]?.segments).toEqual([LOWER, UPPER]);
expect(result.current.layer.count).toBe(1);
act(() => result.current.onViewportChange(OTHER));
expect(result.current.layer.count).toBe(0);
// Out of view, still drawn.
expect(result.current.layer.groups).toHaveLength(1);
});
it('FE-FERRATA-MAPLAYER-006: a click opens the whole ferrata in the card, highlighted, and close shuts it', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
const group = result.current.layer.groups[0];
if (!group) throw new Error('no group');
act(() => result.current.layer.onSelect(group, UPPER, { lat: 46.521, lng: 11.8 }));
expect(result.current.selected?.group.id).toBe(group.id);
expect(result.current.selected?.segment).toBe(UPPER);
expect(result.current.layer.selectedId).toBe(group.id);
act(() => result.current.close());
expect(result.current.selected).toBeNull();
expect(result.current.layer.selectedId).toBeNull();
});
it('FE-FERRATA-MAPLAYER-007: the card follows its ferrata when more of its segments load', () => {
const { result, rerender } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
const group = result.current.layer.groups[0];
if (!group) throw new Error('no group');
act(() => result.current.layer.onSelect(group, UPPER, { lat: 46.521, lng: 11.8 }));
// A lower way loads and joins the ferrata; its id is now the group's.
const lowest = segment(1, 46.519);
vi.mocked(useTripFerratas).mockReturnValue({ ferratas: [LOWER, UPPER, lowest], status: 'ready', truncated: false });
rerender();
expect(result.current.selected?.group.segments).toHaveLength(3);
expect(result.current.selected?.segment).toBe(UPPER);
expect(result.current.layer.selectedId).toBe('group/way/1');
});
it('FE-FERRATA-MAPLAYER-008: switching the layer off closes the card and stops asking', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
const group = result.current.layer.groups[0];
if (!group) throw new Error('no group');
act(() => result.current.layer.onSelect(group, LOWER, { lat: 46.52, lng: 11.8 }));
act(() => result.current.layer.onToggle());
expect(result.current.layer.active).toBe(false);
expect(result.current.selected).toBeNull();
expect(lastCall()?.[2]).toBe(false);
// On again, the card stays closed until the next click.
act(() => result.current.layer.onToggle());
expect(result.current.selected).toBeNull();
});
});
@@ -0,0 +1,108 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import type { Ferrata, FerrataBbox } from '@trek/shared';
import { type FerrataCrossing, ferrataTilesAlong, routeFerrataCrossings } from './ferrataCrossings';
import {
type FerrataGroup,
ferrataGroupOf,
ferrataGroupsInView,
ferrataPointsBbox,
groupFerratas,
} from './ferrataGroups';
import type { FerrataSelectHandler } from './useFerrataLayerGL';
import { type FerrataStatus, useTripFerratas } from './useTripFerratas';
/**
* The via ferrata layer as a map hands it around: whether it is on, the switch
* the layer pill shows with its count badge, the ferratas to draw (grouped
* from OSM's segments), the one open in the card, and what a click on a line
* does. Both flat renderers and the 3D terrain map take this one object, so
* the three cannot drift apart on what the layer is.
*/
export interface FerrataMapLayer {
active: boolean;
onToggle: () => void;
status: FerrataStatus;
/** How many ferratas reach into the view on screen. */
count: number;
groups: readonly FerrataGroup[];
/** The ferrata open in the card, drawn highlighted. */
selectedId: string | null;
onSelect: FerrataSelectHandler;
}
/** The ferrata open in the card, and the OSM way that was picked on it. */
export interface FerrataSelection {
group: FerrataGroup;
segment: Ferrata;
}
/** The box around every point of every line the stage hands over, or null for none. */
export function ferrataAreaOf(lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>): FerrataBbox | null {
return ferrataPointsBbox(lines.flat());
}
/**
* The via ferrata layer of one map surface: off by default, and nothing is
* read or asked for while it is off.
*
* `onViewportChange` takes the visible area from whichever map is on screen
* (the flat planner map or the 3D terrain). While the layer is off the area is
* only remembered, not put into state, so panning a map with the layer off
* re-renders nothing; switching it on starts from the last area the map
* reported instead of waiting for the next pan. `areaLines` are the lines the
* trip's area is made of (tour routes, waypoints, places), prefetched once
* the layer is on. The selection names the OSM way that was clicked, so it
* follows its ferrata when more segments load and join it, and closes when the
* layer is switched off.
*/
export function useFerrataMapLayer(
tripId: number | null | undefined,
areaLines: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [],
/** The tour's line as the elevation profile measures it; the ferratas along it load with the layer off too. */
routeLine: readonly (readonly [number, number])[] | null = null
) {
const [active, setActive] = useState(false);
const [bbox, setBbox] = useState<FerrataBbox | null>(null);
const [selectedSegment, setSelectedSegment] = useState<string | null>(null);
const lastArea = useRef<FerrataBbox | null>(null);
const activeRef = useRef(false);
const area = ferrataAreaOf(areaLines);
const routeTiles = useMemo(() => ferrataTilesAlong(routeLine), [routeLine]);
const { ferratas, status } = useTripFerratas(tripId, bbox, active, area, routeTiles);
const groups = useMemo(() => groupFerratas(ferratas), [ferratas]);
const crossings = useMemo<FerrataCrossing[]>(() => routeFerrataCrossings(routeLine, groups), [routeLine, groups]);
const count = active ? ferrataGroupsInView(groups, bbox) : 0;
const onViewportChange = useCallback((next: FerrataBbox) => {
lastArea.current = next;
if (activeRef.current) setBbox(next);
}, []);
const onToggle = useCallback(() => {
const next = !activeRef.current;
activeRef.current = next;
setActive(next);
setSelectedSegment(null);
if (next) setBbox(lastArea.current);
}, []);
const close = useCallback(() => setSelectedSegment(null), []);
const onSelect = useCallback<FerrataSelectHandler>((_group, segment) => setSelectedSegment(segment.id), []);
const selected = useMemo<FerrataSelection | null>(() => {
if (!active) return null;
const group = ferrataGroupOf(groups, selectedSegment);
const segment = group?.segments.find((candidate) => candidate.id === selectedSegment);
return group && segment ? { group, segment } : null;
}, [active, groups, selectedSegment]);
const selectedId = selected?.group.id ?? null;
const layer = useMemo<FerrataMapLayer>(
() => ({ active, onToggle, status, count, groups, selectedId, onSelect }),
[active, onToggle, status, count, groups, selectedId, onSelect]
);
return { layer, onViewportChange, selected, close, crossings };
}
@@ -0,0 +1,47 @@
// FE-FERRATA-USEPAINT-001 to FE-FERRATA-USEPAINT-003: the line colours follow the theme on <html>.
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { useFerrataPaint } from './useFerrataPaint';
const root = document.documentElement;
afterEach(() => {
root.style.removeProperty('--map-ferrata');
root.classList.remove('dark', 'unrelated');
root.removeAttribute('data-scheme');
});
describe('useFerrataPaint', () => {
it('FE-FERRATA-USEPAINT-001: starts with the colours the tokens resolve to', () => {
root.style.setProperty('--map-ferrata', 'black');
const { result } = renderHook(() => useFerrataPaint());
expect(result.current.line).toBe('black');
});
it('FE-FERRATA-USEPAINT-002: resolves again when the scheme or a token on the root changes', async () => {
root.style.setProperty('--map-ferrata', 'black');
const { result } = renderHook(() => useFerrataPaint());
await act(async () => {
root.setAttribute('data-scheme', 'forest');
root.style.setProperty('--map-ferrata', 'ivory');
await Promise.resolve();
});
await waitFor(() => expect(result.current.line).toBe('ivory'));
});
it('FE-FERRATA-USEPAINT-003: keeps the same reference when a change leaves every colour as it was', async () => {
root.style.setProperty('--map-ferrata', 'black');
const { result } = renderHook(() => useFerrataPaint());
const first = result.current;
await act(async () => {
root.classList.add('unrelated');
await Promise.resolve();
});
expect(result.current).toBe(first);
});
});
@@ -0,0 +1,7 @@
import { useMapPaint } from '../Map/mapPaint';
import { FERRATA_TOKENS, type FerrataPaint } from './ferrataPaint';
/** The line colours, resolved again whenever the theme on `<html>` changes (see `useMapPaint`). */
export function useFerrataPaint(): FerrataPaint {
return useMapPaint(FERRATA_TOKENS);
}
@@ -0,0 +1,34 @@
// FE-FERRATA-GUARD-001: the press that closed a ferrata's card is kept from also acting on the map.
import { act, fireEvent, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useOutsidePressGuard } from './useOutsidePressGuard';
describe('useOutsidePressGuard', () => {
it('FE-FERRATA-GUARD-001: the guard drops the one map click after the closing press and is off once that click went by', () => {
vi.useFakeTimers();
try {
const handler = vi.fn();
const { result } = renderHook(() => useOutsidePressGuard());
const guarded = result.current.guard(handler);
expect(result.current.guard(undefined)).toBeUndefined();
act(() => result.current.arm());
guarded?.('first');
expect(handler).not.toHaveBeenCalled();
guarded?.('second');
expect(handler).toHaveBeenCalledWith('second');
// Armed, but the click lands somewhere that is not the map: the guard comes off right after it.
act(() => result.current.arm());
fireEvent.click(document.body);
act(() => {
vi.runAllTimers();
});
guarded?.('third');
expect(handler).toHaveBeenCalledWith('third');
} finally {
vi.useRealTimers();
}
});
});

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