Compare commits

...

49 Commits

Author SHA1 Message Date
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
834 changed files with 65081 additions and 1879 deletions
+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),
};
+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),
};
+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),
};
+5 -2
View File
@@ -1,7 +1,7 @@
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
import { postMultipart } from './client';
/** A GPX file and the fields beside it, as the Tours import routes read a multipart body. */
/** 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);
@@ -9,7 +9,10 @@ function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
return fd;
}
/** The Tours GPX import: a file read for its preview first (PAD-02), then imported as the type chosen. */
/**
* 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> =>
+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, 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),
};
+9
View File
@@ -1,6 +1,7 @@
import type {
AdminFeatureToggleResponse,
TourSourceCheckResponse,
TourSourceHintResponse,
WandererConnection,
WandererImportPreview,
WandererImportRequest,
@@ -11,6 +12,7 @@ import type {
WandererSettingsRequest,
WandererTestRequest,
WandererTestResponse,
WandererWatch,
} from '@trek/shared';
import apiClient from './client';
@@ -31,6 +33,10 @@ export const wandererApi = {
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),
@@ -43,6 +49,9 @@ export const wandererApi = {
/** 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. */
@@ -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 { render, screen } from '../../../../tests/helpers/render';
import { buildSettings } from '../../../../tests/helpers/factories';
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,234 @@
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 { TourActivityStatsDialog } from './TourActivityStatsDialog';
import { splitMeasure } from './tourActivityStatsModel';
import {
ActivityFilters,
ActivityStatusLine,
BasisBadges,
distanceReading,
heightReading,
typeIcon,
typeLabel,
UncountedBadges,
useDistanceUnit,
} from './tourActivityParts';
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,158 @@
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 { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { EntryTile, MeasurePills, useDistanceUnit } from './tourActivityParts';
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={() => 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 { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { buildSettings } from '../../../../tests/helpers/factories';
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,192 @@
import type { TourActivityTotals, TourTypeKey } from '@trek/shared';
import { Footprints } from 'lucide-react';
import { 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,
distanceReading,
heightReading,
MeasurePills,
partialHint,
typeIcon,
UncountedBadges,
useDistanceUnit,
} from './tourActivityParts';
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 Icon = typeIcon(type);
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">
<Icon 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,269 @@
import type { TourActivityBasis, TourActivityStatsEntry, TourActivityTotals } from '@trek/shared';
import {
AlertTriangle,
ArrowUp,
CalendarClock,
CalendarDays,
CalendarRange,
Footprints,
Layers,
Loader2,
Ruler,
WifiOff,
type LucideIcon,
} from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
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 { periodYear, sumReading, type ActivityPeriod, type ActivityType } from './tourActivityStatsModel';
import type { TourActivityStats } from './useTourActivityStats';
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 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));
}
/** 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);
}
/** 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 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 });
}
/** 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();
const Icon = tourDayRowIcon(entry.tour_type);
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">
<Icon 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,92 @@
// 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', 'kayak', 'run']);
});
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']);
});
});
@@ -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,156 @@
// FE-STA2-HOOK-001 to FE-STA2-HOOK-007
// 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);
});
});
@@ -0,0 +1,139 @@
import {
summarizeTourActivities,
type TourActivityStatsEntry,
type TourActivityStatsResponse,
type TourActivitySummary,
} from '@trek/shared';
import { useCallback, useEffect, useMemo, 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)),
};
}
/**
* 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.
*/
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();
useEffect(() => {
if (!active) return;
const controller = new AbortController();
tourActivityStatsRepo.get(controller.signal).then(
(answer) => {
if (controller.signal.aborted) return;
setData(answer);
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;
setPending((current) => new Set(current).add(placeId));
setData((current) => (current ? flagged(current, placeId, excluded) : current));
try {
setData(await tourActivityStatsRepo.setExcluded({ place_id: placeId, excluded }));
} catch {
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,
};
}
@@ -1,9 +1,10 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-020
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-024
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
describe('MapLayerSwitcher', () => {
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
@@ -235,3 +236,79 @@ describe('TourMapLayerSwitcher via ferrata switch', () => {
expect(screen.getByRole('button', { name: 'Set points' })).toHaveClass('text-content-muted')
})
})
const pisteOverlay = (overrides: Partial<ToggleableRasterOverlay> = {}): ToggleableRasterOverlay => ({
id: 'pistes',
url: 'https://tiles.opensnowmap.org/pistes/{z}/{x}/{y}.png',
attribution: 'OpenSnowMap',
minZoom: 9,
maxZoom: 18,
active: false,
status: 'off',
belowMinZoom: false,
onToggle: vi.fn(),
onTileError: vi.fn(),
onTileLoad: vi.fn(),
...overrides,
})
describe('TourMapLayerSwitcher piste switch (SKI-01)', () => {
it('FE-COMP-MAPLAYER-021: the pistes switch sits on its icon alone, named by its tooltip, and offers no key while off', async () => {
const overlay = pisteOverlay()
render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer()} layers={{ overlays: [overlay] }} />)
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
expect(toggle).toHaveAttribute('aria-pressed', 'false')
expect(toggle.textContent).toBe('')
expect(screen.queryByTestId('piste-key-button')).toBeNull()
fireEvent.click(toggle)
expect(overlay.onToggle).toHaveBeenCalledTimes(1)
fireEvent.mouseEnter(toggle)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Pistes, cross-country trails and lifts')
})
it('FE-COMP-MAPLAYER-022: on, the switch is pressed in the accent and the key rides beside it in the same pill', () => {
render(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on' })] }} />)
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
expect(toggle).toHaveClass('bg-accent', 'text-accent-text')
const key = screen.getByTestId('piste-key-button')
expect(screen.getByRole('group')).toContainElement(key)
expect(key).toHaveAccessibleName('Piste key')
})
it('FE-COMP-MAPLAYER-023: a piste source that stopped answering shows the warning badge, says so, and hides the key', async () => {
render(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'down' })] }} />)
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
expect(toggle).toHaveAttribute('data-status', 'down')
expect(toggle).toHaveTextContent('!')
expect(screen.queryByTestId('piste-key-button')).toBeNull()
expect(screen.getByRole('status')).toHaveTextContent('Pistes are not loading. The map and your route still work.')
fireEvent.mouseEnter(toggle)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Pistes are not loading.')
})
it('FE-COMP-MAPLAYER-024: on further out than the pistes reach, the switch stays pressed in the soft accent and says to zoom in', async () => {
const { rerender } = render(
<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on', belowMinZoom: true })] }} />
)
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
expect(toggle).toHaveAttribute('data-below-min-zoom', 'true')
expect(toggle).toHaveClass('bg-accent-soft', 'text-accent')
expect(screen.getByRole('status')).toHaveTextContent('Zoom in to see the pistes')
fireEvent.mouseEnter(toggle)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Zoom in to see the pistes')
// The key stays at hand and opens with the same line on top.
fireEvent.click(screen.getByTestId('piste-key-button'))
expect(await screen.findByTestId('piste-key-zoom-in')).toHaveTextContent('Zoom in to see the pistes')
// A source that stopped answering says so instead, and close enough the switch is plainly on.
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'down', belowMinZoom: true })] }} />)
expect(screen.getByRole('status')).toHaveTextContent('Pistes are not loading.')
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on' })] }} />)
const plain = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
expect(plain).not.toHaveAttribute('data-below-min-zoom')
expect(plain).toHaveClass('bg-accent', 'text-accent-text')
expect(screen.getByRole('status')).toHaveTextContent('')
})
})
+45 -15
View File
@@ -1,6 +1,7 @@
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
import type { CSSProperties } from 'react'
import { PisteKeyButton } from '../Tours/winter/PisteKey'
import type { ComponentType, CSSProperties } from 'react'
import { useTranslation } from '../../i18n'
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
@@ -193,17 +194,32 @@ function PlaceToolToggle({ tool }: { tool: MapPlaceTool }) {
)
}
/** How each overlay the planner can offer is named and drawn on its switch. */
const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; downKey: string }> = {
seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' },
/** How an overlay the planner can offer is named and drawn on its switch. */
interface OverlayLook {
Icon: typeof Anchor
labelKey: string
downKey: string
/** On its icon alone, named by its tooltip, so the row stays short beside a type's other switches. */
iconOnly?: boolean
/** What the switch says while the map is further out than the overlay's first tiles. */
zoomInKey?: string
/** The overlay's key, beside its switch while the layer is on; told when nothing of it is drawn yet. */
Key?: ComponentType<{ belowMinZoom: boolean }>
}
const OVERLAY_LOOK: Record<string, OverlayLook> = {
seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' },
// SKI-01: a hike's row already holds four maps, the ferratas and 3D, so the pistes go by their icon.
pistes: {
Icon: MountainSnow,
labelKey: 'tours.map.pistes',
downKey: 'tours.map.pistesDown',
zoomInKey: 'tours.map.pistesZoomIn',
iconOnly: true,
Key: PisteKeyButton,
},
}
/**
* The switch for a transparent overlay (the seamarks), the way the via
* ferrata switch looks: on in the accent, and in the warning tone with a badge
* once its source stopped answering, which the tooltip names. The map and the
* planning go on without it.
*/
/** The warning mark on a switch whose source stopped answering; the tooltip says what it means. */
function DownBadge() {
return (
@@ -213,6 +229,15 @@ function DownBadge() {
)
}
/**
* The switch for a transparent overlay (the seamarks, the pistes), the way the via
* ferrata switch looks: on in the accent, and in the warning tone with a badge
* once its source stopped answering, which the tooltip names. The map and the
* planning go on without it. Switched on further out than its first tiles, it
* stays pressed in the soft accent and its tooltip says to zoom in, so an empty
* map does not read as broken. An overlay with a key shows the key's button
* beside the switch while it is on and answering.
*/
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
const { t } = useTranslation()
const look = OVERLAY_LOOK[overlay.id]
@@ -220,9 +245,12 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
const label = t(look.labelKey)
const down = overlay.status === 'down'
const on = overlay.status !== 'off'
const zoomIn = !down && overlay.belowMinZoom && look.zoomInKey ? t(look.zoomInKey) : null
const notice = down ? t(look.downKey) : zoomIn
const tone = zoomIn ? 'bg-accent-soft text-accent' : 'bg-accent text-accent-text'
return (
<>
<Tooltip label={down ? t(look.downKey) : label} placement="top">
<Tooltip label={notice ?? label} placement="top">
<button
type="button"
onClick={overlay.onToggle}
@@ -230,15 +258,17 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
aria-pressed={on}
data-testid={`map-overlay-${overlay.id}`}
data-status={overlay.status}
className={on ? 'bg-accent text-accent-text' : 'text-content-muted'}
data-below-min-zoom={zoomIn ? true : undefined}
className={on ? tone : 'text-content-muted'}
style={segmentStyle(on)}
>
<look.Icon size={15} strokeWidth={2} />
{!compact && <span>{label}</span>}
{!compact && !look.iconOnly && <span>{label}</span>}
{down && <DownBadge />}
</button>
</Tooltip>
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
{look.Key && overlay.status === 'on' && <look.Key belowMinZoom={!!zoomIn} />}
<span role="status" aria-live="polite" className="sr-only">{notice ?? ''}</span>
</>
)
}
+3 -2
View File
@@ -1141,10 +1141,11 @@ describe('MapView plugin route vias', () => {
})
describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-043: reports the viewport bbox once the map is ready and again on every move', () => {
it('FE-COMP-MAPVIEW-043: reports the viewport bbox and its zoom once the map is ready and again on every move', () => {
const onViewportChange = vi.fn()
mapMock.getZoom.mockReturnValue(10)
render(<MapView onViewportChange={onViewportChange} />)
expect(onViewportChange).toHaveBeenCalledWith({ south: 47, west: 1, north: 49, east: 3 })
expect(onViewportChange).toHaveBeenCalledWith({ south: 47, west: 1, north: 49, east: 3 }, 10)
const moveend = handlerFor('moveend')
const zoomend = handlerFor('zoomend')
+3 -3
View File
@@ -340,14 +340,14 @@ function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area.
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
const map = useMap()
useEffect(() => {
if (!onViewportChange) return
const emit = () => {
const b = map.getBounds()
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, map.getZoom())
}
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit)
+4 -2
View File
@@ -2215,13 +2215,15 @@ describe('MapViewGL', () => {
expect((window as any).__trek_map).toBeUndefined()
})
it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox for the POI explore pill', async () => {
it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox for the POI explore pill, with the zoom on the raster tile scale', async () => {
const onViewportChange = vi.fn()
vi.mocked(glMap.getZoom).mockReturnValue(10)
render(<MapViewGL places={[]} fitKey={1} onViewportChange={onViewportChange} />)
await act(async () => {})
act(() => { mapHandler('moveend')() })
expect(onViewportChange).toHaveBeenCalledWith({ south: 0, west: 0, north: 1, east: 1 })
// The GL engine counts on 512 px tiles; a raster tile's zoom is one step further in.
expect(onViewportChange).toHaveBeenCalledWith({ south: 0, west: 0, north: 1, east: 1 }, 11)
})
it('FE-COMP-MAPVIEWGL-060: a new POI or via set replaces the previous overlay markers', async () => {
+6 -3
View File
@@ -8,7 +8,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { applyBaseLayer } from './glRasterLayers'
import { applyBaseLayer, rasterTileZoom } from './glRasterLayers'
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
@@ -1433,10 +1433,13 @@ export function MapViewGL({
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
})
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
// pill can fetch OSM places for the visible area.
// pill can fetch OSM places for the visible area, with the zoom on the raster tile scale.
const emitViewport = () => {
const b = map.getBounds()
onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
onViewportChangeRef.current?.(
{ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() },
rasterTileZoom(map.getZoom()),
)
}
map.on('moveend', emitViewport)
map.once('idle', emitViewport)
@@ -2,7 +2,7 @@ import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { plannerPinBadge, plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The pin's inline style for a size and selection; the renderer owns the colour. */
@@ -20,7 +20,7 @@ function plannerWaypointIcon(
className: 'tour-planner-waypoint-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>${plannerPinBadge(point)}`,
});
}
@@ -46,10 +46,10 @@ export default function PlannerWaypointMarker({
handlersRef.current = { onClick, onMove };
}, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const { label, shaping } = point;
const { label, shaping, stageEnd } = point;
const icon = useMemo(
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
[index, label, onMove, pinCss, selected, shaping]
() => plannerWaypointIcon(pinCss, { label, shaping, stageEnd }, index, selected, !!onMove),
[index, label, onMove, pinCss, selected, shaping, stageEnd]
);
const eventHandlers = useMemo(
() => ({
@@ -23,6 +23,8 @@ function RasterOverlaysLeaflet({ overlays }: { overlays?: readonly MapRasterOver
maxNativeZoom={overlay.maxZoom}
maxZoom={22}
zIndex={OVERLAY_Z_INDEX}
// The page's origin as the referer, which the overlays' operators ask for (OpenSnowMap names it).
referrerPolicy="strict-origin-when-cross-origin"
eventHandlers={{ tileerror: overlay.onTileError, tileload: overlay.onTileLoad }}
/>
))}
+49 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
import { projectDayItinerary } from './dayTourProjection'
import { projectDayItinerary, tourStageStopsByPlace } from './dayTourProjection'
import { resolveLegMode } from '../Planner/legMode'
import { planTripRoute, assembleTripRoute, emptyAnswers, summariseTripRoute } from './tripRouteGeometry'
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'
@@ -437,6 +437,54 @@ describe('Tour day itinerary segments', () => {
})
})
// MDT-02: a day holding one stage of a multi-day tour routes to that stage and draws its stretch.
describe('A stage of a multi-day tour on a day', () => {
const line: [number, number][] = [[48, 11], [48.01, 11], [48.02, 11], [48.03, 11], [48.04, 11]]
const stops = [
{ key: 'stop-start', lat: 48, lng: 11 },
{ key: 'stop-hut', lat: 48.02, lng: 11 },
{ key: 'stop-end', lat: 48.04, lng: 11 },
]
const hutToHut = (order: number, tour_stage_key: string | null) => {
const assignment = at(48, 11, order)
return { ...assignment, tour_place_id: assignment.place.id, tour_route_geometry: JSON.stringify(line), tour_stage_key }
}
it('FE-MAP-DRP-017: draws and routes to the stretch of the stage, the stops telling where it runs', () => {
const second = hutToHut(1, 'stop-hut')
const stageStops = new Map([[second.place.id, stops]])
expect(projectDayItinerary([second], true, [], stageStops)[0]).toMatchObject({
kind: 'tour', valid: true, start: { lat: 48.02, lng: 11 }, end: { lat: 48.04, lng: 11 }, geometry: line.slice(2),
})
const first = hutToHut(1, 'stop-start')
expect(projectDayItinerary([first], true, [], new Map([[first.place.id, stops]]))[0]).toMatchObject({
start: { lat: 48, lng: 11 }, end: { lat: 48.02, lng: 11 },
})
const runs = buildDayRouteRuns(1, {
...inputs({ assignments: { '1': [at(47.99, 11, 0), second, at(48.05, 11, 2)] } }),
toursEnabled: true, tourStageStops: stageStops,
})
expect(runs.map(run => run.map(point => [point.lat, point.lng]))).toEqual([[[47.99, 11], [48.02, 11]], [[48.04, 11], [48.05, 11]]])
})
it('FE-MAP-DRP-018: keeps the whole tour where the stops do not tell the stage', () => {
const whole = hutToHut(1, null)
const unknown = hutToHut(1, 'stop-gone')
const last = hutToHut(1, 'stop-end')
for (const assignment of [whole, unknown, last]) {
expect(projectDayItinerary([assignment], true, [], new Map([[assignment.place.id, stops]]))[0]).toMatchObject({ geometry: line })
}
const noStops = hutToHut(1, 'stop-hut')
expect(projectDayItinerary([noStops], true)[0]).toMatchObject({ geometry: line })
})
it('FE-MAP-DRP-019: reads the stage stops of the multi-day tours off the tour list, none for a day tour', () => {
const stages = tourStageStopsByPlace([{ place_id: 4, stage_stops: stops }, { place_id: 5, stage_stops: null }, { place_id: 6 }])
expect([...stages.keys()]).toEqual([4])
expect(stages.get(4)).toBe(stops)
})
})
// #2600: an imported GPX/KML place sits at its track's first point, so routing it as a
// plain stop drew a second road from the track start to the next stop, alongside the
// track itself. Any assigned place with a track now leaves that stretch to the track.
+4 -2
View File
@@ -3,7 +3,7 @@ import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarri
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import { withinDriveRange } from '@trek/shared/roadtrip'
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
import { projectDayItinerary } from './dayTourProjection'
import { projectDayItinerary, type TourStageStops } from './dayTourProjection'
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
@@ -41,6 +41,8 @@ export interface DayRouteInputs {
optimizeFromAccommodation: boolean | undefined
toursEnabled?: boolean
places?: Place[]
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one routes to it. */
tourStageStops?: TourStageStops
}
/**
@@ -83,7 +85,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
days: allDays,
})
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places).map(item => [item.assignment.id, item]))
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places, input.tourStageStops).map(item => [item.assignment.id, item]))
type Entry =
| { kind: 'place'; id: number; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
+36 -1
View File
@@ -1,5 +1,32 @@
import type { TourListItem, TourStageStop } from '@trek/shared'
import { analyzeRouteGeometry } from '../../utils/routeGeometry'
import type { Assignment, Place } from '../../types'
import { vertexIndicesOf } from '../Tours/stages/tourStages'
/** MDT-02: the stops each multi-day tour's stages run between, by the tour's place id. */
export type TourStageStops = ReadonlyMap<number, readonly TourStageStop[]>
/** The stage stops of the trip's multi-day tours, from the tour list; a day tour has none. */
export function tourStageStopsByPlace(tours: readonly Pick<TourListItem, 'place_id' | 'stage_stops'>[]): TourStageStops {
return new Map(tours.flatMap(tour => (tour.stage_stops ? [[tour.place_id, tour.stage_stops] as const] : [])))
}
/**
* MDT-02: the stretch of a tour's line one stage runs, from the stop it starts
* at to the stop the next one starts at; null where the stops do not tell it
* (a stage the tour no longer has, a line that has not reached them yet).
*/
function stageStretch(
line: [number, number][],
stops: readonly TourStageStop[] | undefined,
stageKey: string | null | undefined,
): [number, number][] | null {
if (!stageKey || !stops || line.length < 2) return null
const index = stops.findIndex(stop => stop.key === stageKey)
if (index < 0 || index >= stops.length - 1) return null
const at = vertexIndicesOf(stops, line)
return at[index + 1] > at[index] ? line.slice(at[index], at[index + 1] + 1) : null
}
export type DayItineraryItem =
| { kind: 'stop'; assignment: Assignment }
@@ -14,10 +41,17 @@ export type DayItineraryItem =
valid: boolean
}
/**
* A day's stops with each tour projected onto its line: the day's route runs
* to where the tour starts and on from where it ends. A day holding one stage
* of a multi-day tour (MDT-02) runs to that stage's start and on from its end,
* and draws that stage's stretch, where `stageStops` tells them.
*/
export function projectDayItinerary(
assignments: Assignment[],
toursEnabled: boolean,
places: readonly Place[] = [],
stageStops?: TourStageStops,
): DayItineraryItem[] {
const placesById = new Map(places.map(place => [place.id, place]))
return assignments.map(assignment => {
@@ -37,7 +71,8 @@ export function projectDayItinerary(
loop: start.lat === end.lat && start.lng === end.lng, valid: true } as const
}
const analysis = analyzeRouteGeometry(currentPlace ? currentPlace.route_geometry : assignment.tour_route_geometry)
const geometry = analysis?.routeCoordinates ?? []
const whole = analysis?.routeCoordinates ?? []
const geometry = stageStretch(whole, stageStops?.get(placeId), assignment.tour_stage_key) ?? whole
const start = geometry.length >= 2
? { lat: geometry[0][0], lng: geometry[0][1] }
: assignment.place?.lat != null && assignment.place?.lng != null
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { applyBaseLayer, tourRasterSourceId } from './glRasterLayers';
import { applyBaseLayer, rasterTileZoom, tourRasterSourceId } from './glRasterLayers';
/** Enough of a GL map for the base layers: sources, layers with their visibility, and the style's order. */
function fakeMap(styleLayers: Array<{ id: string }> = [{ id: 'water' }, { id: 'trip-route' }]) {
@@ -53,3 +53,10 @@ describe('applyBaseLayer (MAP-01)', () => {
expect(() => applyBaseLayer(map, 'hiking')).not.toThrow();
});
});
describe('rasterTileZoom', () => {
it('puts a GL zoom on the 256 px tile scale one step further in, where a raster minimum is read', () => {
expect(rasterTileZoom(8)).toBe(9);
expect(rasterTileZoom(7.5)).toBe(8.5);
});
});
@@ -27,6 +27,16 @@ const SATELLITE_LAYER_ID = 'trip-satellite-raster';
// `trip-` layer, and anchoring the imagery there painted right over them.
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
/**
* A GL map's zoom on the 256 px tile scale that raster tile URLs, their
* min and max zoom, and Leaflet count in. The GL engines count on 512 px
* tiles, so the same view is one step further in on that scale, and a 256 px
* raster source is drawn from its tiles one level up.
*/
export function rasterTileZoom(glZoom: number): number {
return glZoom + 1;
}
/**
* Puts the imagery on the map, or takes it off again.
*
@@ -31,6 +31,8 @@ type RasterOverlayStatus = 'off' | 'on' | 'down';
/** An overlay with its switch: whether it is on, and whether its source stopped answering. */
export interface ToggleableRasterOverlay extends MapRasterOverlay {
status: RasterOverlayStatus;
/** On, but the map is further out than its first tiles, so nothing of it is drawn yet. */
belowMinZoom: boolean;
onToggle: () => void;
}
+7 -1
View File
@@ -118,6 +118,8 @@ export interface MapViewProps {
detail?: string;
label?: string;
shaping?: boolean;
/** MDT-01: a stage of a multi-day tour ends here; the pin carries a moon at its corner. */
stageEnd?: boolean;
}>;
selectedPlannerWaypointId?: string | null;
onPlannerWaypointClick?: (id: string) => void;
@@ -180,7 +182,11 @@ export interface MapViewProps {
roadtripVias?: Record<number, RoadtripVia[]>;
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void;
onRemoveVia?: (dayId: number, id: number) => void;
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void;
/**
* The visible area after every move, and the zoom on the 256 px tile scale
* raster tile URLs count in (a GL map's own zoom is one step lower there).
*/
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }, zoom: number) => void;
/**
* The raster tile URL template. Only the Leaflet renderer draws raster tiles;
* the GL renderer draws the style of the chosen provider and ignores it.
@@ -1,6 +1,7 @@
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
// FE-PLANNER-PIN-002: the stop where a stage of a multi-day tour ends carries the moon at its corner (MDT-01).
import { describe, expect, it } from 'vitest';
import { plannerPinShape } from './plannerPinShape';
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
describe('plannerPinShape', () => {
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
@@ -9,4 +10,13 @@ describe('plannerPinShape', () => {
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
});
it('FE-PLANNER-PIN-002: marks a stage end with the moon at the corner, and nothing else', () => {
const badge = plannerPinBadge({ stageEnd: true, label: '3' });
expect(badge).toContain('data-stage-end="true"');
expect(badge).toContain('<svg');
expect(badge).toContain('inset-inline-end');
expect(plannerPinBadge({ label: '3' })).toBe('');
expect(plannerPinBadge({ stageEnd: true, shaping: true })).toBe('');
});
});
@@ -1,8 +1,14 @@
import { Moon } from 'lucide-react';
import { createElement } from 'react';
import { renderIconMarkup } from '../../utils/iconMarkup';
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
export interface PlannerPinPoint {
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
label?: string;
shaping?: boolean;
/** MDT-01: a stage of a multi-day tour ends at this stop. */
stageEnd?: boolean;
}
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
@@ -14,3 +20,15 @@ export function plannerPinShape(
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
}
const MOON = renderIconMarkup(createElement(Moon, { size: 9, strokeWidth: 2.6, 'aria-hidden': true }));
/**
* MDT-01: the moon at the corner of a pin where a stage ends, the night
* between two days of the tour, as the planner's list marks the stop's number.
* Laid over the pin, so it needs no room of its own and moves with it.
*/
export function plannerPinBadge(point: PlannerPinPoint): string {
if (!point.stageEnd || point.shaping) return '';
return `<span data-stage-end="true" aria-hidden="true" style="position:absolute;bottom:-4px;inset-inline-end:-4px;display:grid;place-items:center;width:15px;height:15px;border-radius:9999px;background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow-sm);pointer-events:none">${MOON}</span>`;
}
@@ -107,6 +107,7 @@ export function planTripRoute(input: TripRouteInputs, profile: RouteProfileKey):
(input.assignments[String(day.id)] ?? []).filter(assignment => assignment.accommodation_id == null),
input.toursEnabled ?? false,
input.places,
input.tourStageStops,
).flatMap(item => item.kind === 'tour' && item.geometry.length >= 2 ? [item.geometry] : []),
}))
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
@@ -0,0 +1,35 @@
// FE-PLANNER-PIN-GL-001: the GL map's planner pins carry the moon where a stage of a multi-day tour ends (MDT-01).
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL';
describe('usePlannerWaypointMarkersGL', () => {
it('FE-PLANNER-PIN-GL-001: puts the moon on the pin of a stage end only, numbered as the list numbers it', () => {
const container = document.createElement('div');
const map = { getContainer: () => container, unproject: () => ({ lat: 0, lng: 0 }) };
const attached: HTMLElement[] = [];
renderHook(() =>
usePlannerWaypointMarkersGL({
mapRef: { current: map },
mapReady: true,
pinsRef: { current: [] },
attachPin: (el) => {
attached.push(el);
return { remove: vi.fn(), setLngLat: vi.fn() };
},
pinCss: () => 'display:flex',
plannerWaypoints: [
{ id: 'a', lat: 47, lng: 11, label: '1' },
{ id: 'b', lat: 47.1, lng: 11, label: '2', stageEnd: true },
],
selectedPlannerWaypointId: null,
onPlannerWaypointClick: vi.fn(),
onPlannerWaypointMove: undefined,
})
);
expect(attached).toHaveLength(2);
expect(attached[0].querySelector('[data-stage-end]')).toBeNull();
expect(attached[1].querySelector('[data-stage-end]')).not.toBeNull();
expect(attached[1].textContent).toBe('2');
});
});
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { plannerPinShape } from './plannerPinShape';
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
@@ -79,6 +79,9 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
const name = point.name ?? `Waypoint ${index + 1}`;
el.setAttribute('aria-label', name);
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
// MDT-01: the moon where a stage ends; the pin is positioned, so the badge sits at its corner.
const badge = plannerPinBadge(point);
if (badge) el.insertAdjacentHTML('beforeend', badge);
const swallow = (event: Event) => event.stopPropagation();
const select = (event: Event) => {
event.stopPropagation();
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import type { RoadtripVia } from '@trek/shared'
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
import type { TourStageStops } from './dayTourProjection'
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
@@ -46,6 +47,8 @@ export function useTripRouteOverview(
enabled: boolean,
places: Place[] = EMPTY_PLACES,
vias?: TripRouteVias,
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one draws that stage. */
tourStageStops?: TourStageStops,
): TripRouteOverview {
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
@@ -66,9 +69,9 @@ export function useTripRouteOverview(
const viasByDay = vias?.viasByDay
const plan = useMemo(
() => (enabled
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay }, profile)
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay, tourStageStops }, profile)
: []),
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay],
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay, tourStageStops],
)
// Only geometry and mode decide whether legs have to be fetched again: renaming a
@@ -519,6 +519,23 @@ describe('valhallaAlternates', () => {
expect(bodies[1]).not.toHaveProperty('alternates')
})
it('VALHALLA-ALT-005: a section of a hike is offered ways on foot under its own limit and preferences (RTE-02)', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json(withAlternates([answer({ time: 12_000 })]))
}))
const ways = await valhallaAlternates(HAMBURG, BERLIN, 'walking', [], 3, undefined, { max_hiking_difficulty: 4, use_hills: 0.2 })
expect(ways).toHaveLength(2)
expect(bodies[0]).toMatchObject({
alternates: 3,
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 4, use_hills: 0.2 } },
})
})
it('VALHALLA-ALT-004: a refusal is asked once more, then is no answer; an instance with its own OSRM asks nothing', async () => {
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return new HttpResponse(null, { status: 429 }) }))
@@ -551,6 +568,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
expect(bodies[0].shape).toEqual(SHAPE_POINTS.map(([lat, lon]) => ({ lat, lon })))
})
it('VALHALLA-TRACE-005: a run is matched on foot without a hiking limit, and every line is asked its hiking grades (RUN-02)', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(TRACE, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ shape: SHAPE, edges: [] })
}))
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'run')
expect(bodies[0]).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
shape_match: 'map_snap',
})
expect((bodies[0].filters as { attributes: string[] }).attributes).toContain('edge.sac_scale')
})
it('VALHALLA-TRACE-002: a long line is thinned evenly to what one request carries, its ends kept', async () => {
let shape: unknown[] = []
server.use(http.post(TRACE, async ({ request }) => {
@@ -574,6 +606,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
})
it('VALHALLA-TRACE-006: a hike is matched on foot with every grade of path open, never by its routing limit (MAP-03)', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(TRACE, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ shape: SHAPE, edges: [] })
}))
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'foot')
expect(bodies[0]).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
shape_match: 'map_snap',
})
expect(JSON.stringify(bodies[0])).not.toContain('sac_scale')
})
it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => {
let asked = false
server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) }))
+47 -12
View File
@@ -368,14 +368,48 @@ const TRACE_MAX_POINTS = 4000
const TRACE_TIMEOUT_MS = 30_000
/**
* BIK-01: how the router describes the ways under a line (`trace_attributes`):
* the surface, the use and road class, the cycle lane and the OpenStreetMap way
* of every edge, with the line as matched. The edges are matched as a mountain
* bike would ride them, the costing that refuses the fewest ways, so a road
* bike's route and a gravel one are described alike. Null for every failure:
* the description is an addition beside a route that already exists.
* How a line is matched to the ways under it: as a mountain bike rides, the
* bicycle costing that refuses the fewest ways, so a road bike's route and a
* gravel one are described alike; or on foot with every grade of path open,
* since a hike's line (MAP-03) may run on a T6 ridge the matcher must not
* refuse, and steps and mountain paths a run takes (RUN-02) stay on the line
* (matched as a bike, a trail run over steps was described as the street
* beside them). Matching is no routing: the limit a hike was planned with
* plays no part. A hike and a run are matched alike; they differ in whether
* the router's own hiking grade is asked for.
*/
export async function valhallaTraceAttributes(line: readonly [number, number][], signal?: AbortSignal): Promise<unknown> {
const TRACE_COSTING = {
bike: { costing: 'bicycle', costing_options: { bicycle: { bicycle_type: 'Mountain' } } },
foot: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } },
run: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } },
} as const
export type TraceMatching = keyof typeof TRACE_COSTING
const TRACE_ATTRIBUTES = ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length',
'edge.way_id', 'edge.begin_shape_index', 'edge.end_shape_index']
/** The router's hiking grade is asked along a bike tour's and a run's line, never a hike's (MAP-03 reads OpenStreetMap). */
function traceAttributesFor(matching: TraceMatching): string[] {
return matching === 'foot' ? TRACE_ATTRIBUTES : [...TRACE_ATTRIBUTES, 'edge.sac_scale']
}
/**
* BIK-01, RUN-02 and MAP-03: how the router describes the ways under a line
* (`trace_attributes`): the surface, the use and road class, the cycle lane,
* the OpenStreetMap way of every edge and, but for a hike, the router's
* hiking grade (`sac_scale`, 0 where the way has none), with the line as
* matched. A run's grade shares (RUN-02) read the router's grade; the grade a
* hike shows (MAP-03) comes from OpenStreetMap itself, since the router's
* reading may be normalised or derived, so it is not asked for there. Null for
* every failure: the description is an addition beside a route that already
* exists.
*/
export async function valhallaTraceAttributes(
line: readonly [number, number][],
signal?: AbortSignal,
matching: TraceMatching = 'bike'
): Promise<unknown> {
const base = valhallaBase()
if (!base || line.length < 2) return null
const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS))
@@ -389,12 +423,10 @@ export async function valhallaTraceAttributes(line: readonly [number, number][],
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
body: JSON.stringify({
shape,
costing: 'bicycle',
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
...TRACE_COSTING[matching],
shape_match: 'map_snap',
filters: {
attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length', 'edge.way_id',
'edge.begin_shape_index', 'edge.end_shape_index'],
attributes: traceAttributesFor(matching),
action: 'include',
},
}),
@@ -428,6 +460,8 @@ async function requestRun(
* quickest road beside a day Valhalla had routed was offered as the faster way, and
* taking it changed nothing. Valhalla answers `alternates` between exactly two locations,
* which is all one leg is, and weighs every one of them the way it weighed the day.
* `costingOptions` carries the rest of how that day was weighed, such as a hike's limit
* and preferences (RTE-02), so a section of a tour is offered ways under its own rules.
*/
export async function valhallaAlternates(
from: Waypoint,
@@ -436,10 +470,11 @@ export async function valhallaAlternates(
avoid: readonly AvoidClass[],
count: number,
signal?: AbortSignal,
costingOptions: Readonly<CostingOptions> = {},
): Promise<ValhallaLeg[] | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base)) return null
const body = { ...routeBody([from, to], profile, avoid), ...(count > 0 ? { alternates: count } : {}) }
const body = { ...routeBody([from, to], profile, avoid, costingOptions), ...(count > 0 ? { alternates: count } : {}) }
return askWithRetry(base, async () => {
const data = await postRoute(base, body, signal)
return data === null ? null : alternatesFrom(data)
@@ -15,8 +15,9 @@ export const KAT_COLORS = [
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
// A category's first item is seeded with this sentinel because the server
// rejects empty names. Treat it as a placeholder in the UI.
export const PACKING_PLACEHOLDER_NAME = '...'
// rejects empty names. Treat it as a placeholder in the UI. Shared, because a
// tour's packing link (P2D-XD-01) leaves it out the same way.
export { PACKING_PLACEHOLDER_NAME } from '@trek/shared'
/** What the import's file picker offers: CSV rows, plain text, or a Markdown list (#875). */
export const PACKING_IMPORT_ACCEPT = '.csv,.txt,.md,.markdown,text/markdown'
@@ -7,6 +7,7 @@ import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
import { stageNumberOf, TourStageDayPills } from '../Tours/stages/TourStageDayPills';
import { movingTimeLabel } from '../Tours/tourFacts';
import { formatPlannedTourDuration, plannedTotalText, type TourPlannedTimes } from '../Tours/tourPresentation';
import { WalkProgressPill } from '../Tours/walk/WalkStations';
@@ -22,6 +23,8 @@ interface DayPlanPlaceSublineProps {
timeFormat: string;
/** CW-03: the row's day assignment, which a city walk's progress belongs to. */
assignmentId?: number;
/** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; absent for the whole tour. */
tourStageKey?: string | null;
}
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
@@ -71,6 +74,12 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
);
}
// The tour's own pills, or a stage's where the day holds one stage of a multi-day tour (MDT-02).
function DayRowTourPills({ tour, stageKey }: { tour: TourListItem; stageKey?: string | null }) {
if (stageKey && stageNumberOf(tour, stageKey) !== null) return <TourStageDayPills tour={tour} stageKey={stageKey} />;
return <TourDayRowPills tour={tour} />;
}
// Description line, one line like every stop's.
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
return (
@@ -100,6 +109,7 @@ export function DayPlanPlaceSubline({
isTourPlace,
timeFormat,
assignmentId,
tourStageKey,
}: DayPlanPlaceSublineProps) {
const { locale } = useTranslation();
const dayRowTour = tourTimes?.tour;
@@ -115,7 +125,7 @@ export function DayPlanPlaceSubline({
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</TimePill>
)}
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{dayRowTour ? <DayRowTourPills tour={dayRowTour} stageKey={tourStageKey} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{dayRowTour && <WalkProgressPill tour={dayRowTour} assignmentId={assignmentId} />}
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
</div>
@@ -69,7 +69,7 @@ import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnec
import { resolveLegMode } from './legMode'
import { useLegModeActions } from './useLegModeActions'
import { dayExportStops, fillAroundLocked } from './dayRoute'
import { projectDayItinerary } from '../Map/dayTourProjection'
import { projectDayItinerary, type TourStageStops } from '../Map/dayTourProjection'
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
@@ -94,6 +94,8 @@ export interface DayPlanSidebarProps {
places: Place[]
tourPlaceIds?: ReadonlySet<number>
plannedTourDurations?: ReadonlyMap<number, TourPlannedTimes & { tour?: TourListItem }>
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one routes to that stage. */
tourStageStops?: TourStageStops
categories: Category[]
assignments: AssignmentsMap
selectedDayId: number | null
@@ -621,7 +623,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// legs to draw. Side-effect free, so the async loop below only does OSRM I/O.
const planDay = (dayId: number) => {
const merged = mergedItemsMap[dayId] || []
const projected = new Map(projectDayItinerary(assignments[String(dayId)] || [], toursEnabled, places).map(item => [item.assignment.id, item]))
const projected = new Map(projectDayItinerary(assignments[String(dayId)] || [], toursEnabled, places, props.tourStageStops).map(item => [item.assignment.id, item]))
// Each run point carries the ORIGIN assignment's per-segment travel mode
// (#1281): the leg from this point to the next is routed with `mode` (null =
// inherit the day default). Transport endpoints carry no mode → day default.
@@ -840,7 +842,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// routeDayIds is memoized from the same inputs as routeDayKey below, so keying the
// effect on the string is equivalent while staying stable across unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places, roadtripViasByDay])
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places, roadtripViasByDay, props.tourStageStops])
const openAddNote = (dayId, e) => {
e?.stopPropagation()
@@ -2030,7 +2032,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</Tooltip>
)}
</div>
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} />
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} tourStageKey={assignment.tour_stage_key} />
{assignment.notes && (
// Day-specific note on this stop (#2163) — one muted
// caption line so the timeline shows the note exists
@@ -64,4 +64,26 @@ describe('ToggleSwitch', () => {
rerender(<ToggleSwitch on={true} onToggle={() => {}} />);
expect(button.querySelector('span')!.style.left).toBe('22px');
});
it('FE-COMP-TOGGLESWITCH-009: a disabled switch is out of reach and says so', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(<ToggleSwitch on={false} onToggle={onToggle} disabled />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(button.style.cursor).toBe('not-allowed');
await user.click(button);
await user.tab();
expect(onToggle).not.toHaveBeenCalled();
expect(button).not.toHaveFocus();
});
it('FE-COMP-TOGGLESWITCH-010: an enabled switch keeps the pointer and no aria-disabled', () => {
render(<ToggleSwitch on={false} onToggle={() => {}} />);
const button = screen.getByRole('button');
expect(button).toBeEnabled();
expect(button).not.toHaveAttribute('aria-disabled');
expect(button.style.cursor).toBe('pointer');
});
});
@@ -5,16 +5,18 @@ const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
/**
* `describedBy` names the element that explains the switch, for a switch whose label alone
* does not say what flipping it changes.
* does not say what flipping it changes. `disabled` takes it out of the tab order and tells
* assistive tech it cannot act; the caller dims it and says why.
*/
export default function ToggleSwitch({ on, onToggle, label, describedBy }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string }) {
export default function ToggleSwitch({ on, onToggle, label, describedBy, disabled = false }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string; disabled?: boolean }) {
return (
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
disabled={disabled} aria-disabled={disabled || undefined}
// A keyboard focus ring in the text colour, like the fields; the pointer gets none.
className="group outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
style={{
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
borderRadius: 12, border: 'none', padding: 0, cursor: disabled ? 'not-allowed' : 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
boxShadow: 'inset 0 1px 2px color-mix(in srgb, var(--text-primary) 12%, transparent)',
transition: `background 0.2s ${EASE}`,
@@ -16,11 +16,14 @@ import {
} from './settingsKit';
import ToggleSwitch from './ToggleSwitch';
import { useWandererConnection } from './useWandererConnection';
import WandererWatchRow from './WandererWatchRow';
/**
* Settings → Integrations → wanderer: the reader's own trail database, from
* which the Tours workspace imports trails. Laid out like the Dawarich and
* AirTrail cards beside it; the behaviour is in `useWandererConnection`.
* Under the form, the optional background check of the imported trails
* (WAN-02), which switches on its own and needs no save.
*/
export default function WandererConnectionSection(): React.ReactElement {
const { t } = useTranslation();
@@ -136,6 +139,8 @@ export default function WandererConnectionSection(): React.ReactElement {
)}
</div>
)}
<WandererWatchRow refreshKey={S.checkKey} />
</Section>
);
}
@@ -0,0 +1,104 @@
import userEvent from '@testing-library/user-event';
import type { WandererSourceHintItem, WandererWatch } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import WandererWatchRow from './WandererWatchRow';
const repo = vi.hoisted(() => ({ getWatch: vi.fn(), setWatch: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
const navigate = vi.hoisted(() => vi.fn());
vi.mock('../../repo/wandererRepo', () => ({ wandererRepo: repo }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('react-router', async () => ({
...(await vi.importActual<typeof import('react-router')>('react-router')),
useNavigate: () => navigate,
}));
const off: WandererWatch = {
enabled: false,
connected: true,
lastRunAt: null,
pausedUntil: null,
blocked: false,
hints: [],
};
const hints: WandererSourceHintItem[] = [
{
placeId: 7,
tripId: 3,
tripTitle: 'Alps',
tourName: 'Ridge',
state: 'changed',
changes: ['name'],
reason: null,
externalUpdated: '2026-10-14',
checkedAt: '2026-10-20T10:00:00Z',
},
{
placeId: 8,
tripId: 3,
tripTitle: 'Alps',
tourName: 'Gorge',
state: 'unverifiable',
changes: [],
reason: 'unavailable',
externalUpdated: null,
checkedAt: '2026-10-19T10:00:00Z',
},
];
beforeEach(() => {
vi.clearAllMocks();
});
describe('WandererWatchRow (WAN-02)', () => {
it('is off by default and dimmed, not hidden, without a connection', async () => {
repo.getWatch.mockResolvedValue({ ...off, connected: false });
render(<WandererWatchRow refreshKey="false||" />);
await waitFor(() => expect(repo.getWatch).toHaveBeenCalled());
const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
expect(toggle).toBeDisabled();
expect(toggle).toHaveAttribute('aria-disabled', 'true');
await userEvent.tab();
expect(toggle).not.toHaveFocus();
expect(screen.queryByTestId('wanderer-watch-status')).toBeNull();
});
it('switches on at once and then says when it last looked', async () => {
repo.getWatch.mockResolvedValue(off);
repo.setWatch.mockResolvedValue({ ...off, enabled: true });
render(<WandererWatchRow refreshKey="true||" />);
const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false });
await waitFor(() => expect(toggle).toBeEnabled());
await userEvent.click(toggle);
expect(repo.setWatch).toHaveBeenCalledWith(true);
expect(await screen.findByText('Not looked yet')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Hints/ })).toBeDisabled();
});
it('counts the open hints on the button and opens each tour from the dialog', async () => {
repo.getWatch.mockResolvedValue({ ...off, enabled: true, lastRunAt: '2026-10-20T10:00:00Z', hints });
render(<WandererWatchRow refreshKey="true||" />);
expect(await screen.findByTestId('wanderer-hint-count')).toHaveTextContent('2');
await userEvent.click(screen.getByRole('button', { name: /Hints/ }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Hints on your trails')).toBeInTheDocument();
expect(within(dialog).getByText('2 open hints')).toBeInTheDocument();
const ridge = within(dialog).getByTestId('wanderer-hint-7');
expect(within(ridge).getByText('Changed')).toBeInTheDocument();
expect(within(ridge).getByText('Name')).toBeInTheDocument();
expect(within(within(dialog).getByTestId('wanderer-hint-8')).getByText('Trail out of reach')).toBeInTheDocument();
await userEvent.click(ridge);
expect(navigate).toHaveBeenCalledWith('/trips/3?tab=plan&place=7');
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('names a refused token instead of a last look', async () => {
repo.getWatch.mockResolvedValue({ ...off, enabled: true, blocked: true });
render(<WandererWatchRow refreshKey="true||" />);
expect(await screen.findByText('Token refused')).toBeInTheDocument();
});
});
@@ -0,0 +1,107 @@
import { BellRing } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { InfoDot } from '../Tours/tourParts';
import WandererHintsDialog from '../Tours/wanderer/WandererHintsDialog';
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit';
import ToggleSwitch from './ToggleSwitch';
import { useWandererWatch, type WandererWatchController } from './useWandererWatch';
/** The switch, dimmed with a word on why while there is no connection to watch with. */
function WatchSwitch({ W }: { W: WandererWatchController }) {
const { t } = useTranslation();
const toggle = (
<ToggleSwitch
on={W.enabled}
onToggle={() => void W.toggle()}
label={t('wanderer.watch.label')}
disabled={!W.available}
/>
);
if (W.available) return toggle;
// A disabled button takes no focus and, in some browsers, no hover either, so
// the reason sits on a focusable wrapper the tooltip listens to.
return (
<Tooltip label={t('wanderer.watch.notConnected')}>
<span
tabIndex={0}
className="inline-flex cursor-not-allowed rounded-full opacity-50 outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
>
<span className="pointer-events-none inline-flex">{toggle}</span>
</span>
</Tooltip>
);
}
/** The open hints behind a button, their number as a badge on its corner; dimmed while there are none. */
function HintsButton({ W }: { W: WandererWatchController }) {
const { t } = useTranslation();
const count = W.hints.length;
return (
<Tooltip label={count ? t('wanderer.watch.hintsCount', { count }) : t('wanderer.watch.empty')}>
<span className="inline-flex">
<button
type="button"
onClick={W.openHints}
disabled={count === 0}
className={`${SETTINGS_BUTTON} relative`}
style={fs(12.5, 'body')}
>
<BellRing size={13} strokeWidth={2.2} />
{t('wanderer.watch.hints')}
{count > 0 && (
<span
aria-label={t('wanderer.watch.hintsCount', { count })}
data-testid="wanderer-hint-count"
className="absolute -end-1.5 -top-1.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-[color:var(--bg-card)]"
style={fs(10)}
>
{count}
</span>
)}
</button>
</span>
</Tooltip>
);
}
/**
* WAN-02 in the wanderer card: the optional background check of the user's
* own imported trails. Off until switched on; while on, what it last did and
* its open hints, which open in a dialog that leads to each tour.
*/
export default function WandererWatchRow({ refreshKey }: { refreshKey: string }): React.ReactElement {
const { t } = useTranslation();
const W = useWandererWatch(refreshKey);
return (
<SettingRows>
<SettingRow
label={
<span className="inline-flex items-center gap-1.5">
{t('wanderer.watch.label')}
<InfoDot label={t('wanderer.watch.label')} description={t('wanderer.watch.info')} />
</span>
}
hint={t('wanderer.watch.hint')}
dimmed={W.saving}
control={<WatchSwitch W={W} />}
>
{W.status && (
<div className="flex flex-wrap items-center gap-2" data-testid="wanderer-watch-status">
<Tooltip label={W.status.why ?? ''} disabled={!W.status.why}>
<span className="inline-flex">
<StatusPill tone={W.status.tone}>{W.status.text}</StatusPill>
</span>
</Tooltip>
<span className="ms-auto">
<HintsButton W={W} />
</span>
</div>
)}
</SettingRow>
<WandererHintsDialog open={W.hintsOpen} hints={W.hints} onClose={W.closeHints} onOpenTour={W.openTour} />
</SettingRows>
);
}
@@ -27,6 +27,7 @@ export const EVENT_LABEL_KEYS: Record<string, string> = {
photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage',
packing_tagged: 'settings.notifyPackingTagged',
wanderer_source_hint: 'settings.notifyWandererSourceHint',
version_available: 'settings.notifyVersionAvailable',
}
@@ -129,6 +129,13 @@ export function useWandererConnection() {
saved?.lastCheckedAt && saved.lastCheckOk === false && saved.lastCheckError
? errorText(saved.lastCheckError)
: null;
/**
* Changes whenever the server's view of the connection does: connecting,
* disconnecting, a saved token (which clears the last check) or a test that
* stored a new result. The watch row re-reads itself on it, since each of
* those can lift or set its "token refused" state.
*/
const checkKey = `${connected}|${saved?.lastCheckedAt ?? ''}|${saved?.lastCheckOk ?? ''}`;
return {
url,
@@ -145,6 +152,7 @@ export function useWandererConnection() {
tokenInvalid,
canSave,
lastCheck,
checkKey,
save,
test,
disconnect,
@@ -0,0 +1,148 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import type { WandererSourceHintItem, WandererWatch } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useWandererWatch, watchStatusOf } from './useWandererWatch';
const repo = vi.hoisted(() => ({ getWatch: vi.fn(), setWatch: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
const navigate = vi.hoisted(() => vi.fn());
vi.mock('../../repo/wandererRepo', () => ({ wandererRepo: repo }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key),
locale: 'en',
}),
}));
vi.mock('react-router', async () => ({
...(await vi.importActual<typeof import('react-router')>('react-router')),
useNavigate: () => navigate,
}));
const t = (key: string) => key;
const off: WandererWatch = {
enabled: false,
connected: true,
lastRunAt: null,
pausedUntil: null,
blocked: false,
hints: [],
};
const hint: WandererSourceHintItem = {
placeId: 7,
tripId: 3,
tripTitle: 'Alps',
tourName: 'Ridge',
state: 'possible_change',
changes: [],
reason: null,
externalUpdated: '2026-10-14',
checkedAt: '2026-10-20T10:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
repo.getWatch.mockResolvedValue(off);
repo.setWatch.mockImplementation(async (enabled: boolean) => ({ ...off, enabled }));
});
describe('watchStatusOf (WAN-02)', () => {
const now = Date.parse('2026-10-20T12:00:00Z');
it('says nothing while the watch is off', () => {
expect(watchStatusOf(null, t, 'en', now)).toBeNull();
expect(watchStatusOf(off, t, 'en', now)).toBeNull();
});
it('names a refused token first, then a pause still running, then the last look', () => {
const on = { ...off, enabled: true };
expect(watchStatusOf({ ...on, blocked: true, pausedUntil: '2026-10-21T00:00:00Z' }, t, 'en', now)).toEqual({
tone: 'danger',
text: 'wanderer.watch.blocked',
why: 'wanderer.watch.blockedHint',
});
expect(watchStatusOf({ ...on, pausedUntil: '2026-10-20T14:00:00Z' }, t, 'en', now)).toMatchObject({
tone: 'warning',
text: 'wanderer.watch.paused',
why: 'wanderer.watch.pausedHint',
});
expect(
watchStatusOf({ ...on, pausedUntil: '2026-10-20T11:00:00Z', lastRunAt: '2026-10-20T10:00:00Z' }, t, 'en', now)
).toEqual({ tone: 'neutral', text: 'wanderer.watch.lastRun', why: null });
expect(watchStatusOf(on, t, 'en', now)).toEqual({ tone: 'neutral', text: 'wanderer.watch.neverRun', why: null });
});
});
describe('useWandererWatch (WAN-02)', () => {
it('loads with the card and again when the connection comes or goes', async () => {
const { result, rerender } = renderHook(({ refreshKey }) => useWandererWatch(refreshKey), {
initialProps: { refreshKey: 'true||' },
});
await waitFor(() => expect(result.current.available).toBe(true));
expect(result.current.enabled).toBe(false);
expect(result.current.status).toBeNull();
rerender({ refreshKey: 'true||' });
expect(repo.getWatch).toHaveBeenCalledTimes(1);
rerender({ refreshKey: 'false||' });
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
});
it('drops a refused token once a connection test passes, without a reload', async () => {
repo.getWatch.mockResolvedValueOnce({ ...off, enabled: true, blocked: true, pausedUntil: '2999-01-01T00:00:00Z' });
repo.getWatch.mockResolvedValueOnce({ ...off, enabled: true, lastRunAt: '2026-10-20T10:00:00Z' });
const { result, rerender } = renderHook(({ refreshKey }) => useWandererWatch(refreshKey), {
initialProps: { refreshKey: 'true|2026-10-20T09:00:00Z|false' },
});
await waitFor(() => expect(result.current.status?.text).toBe('wanderer.watch.blocked'));
rerender({ refreshKey: 'true|2026-10-20T11:00:00Z|true' });
await waitFor(() => expect(result.current.status?.tone).toBe('neutral'));
expect(result.current.status?.text).toMatch(/^wanderer\.watch\.lastRun/);
});
it('switches at once, and says so either way', async () => {
const { result } = renderHook(() => useWandererWatch('true||'));
await waitFor(() => expect(result.current.available).toBe(true));
await act(async () => {
await result.current.toggle();
});
expect(repo.setWatch).toHaveBeenCalledWith(true);
expect(result.current.enabled).toBe(true);
expect(toast.success).toHaveBeenCalledWith('wanderer.watch.on');
repo.setWatch.mockRejectedValueOnce(new Error('down'));
await act(async () => {
await result.current.toggle();
});
expect(toast.error).toHaveBeenCalledWith('wanderer.watch.failed');
expect(result.current.enabled).toBe(true);
});
it('leaves the switch alone without a connection, or when the watch could not be read', async () => {
repo.getWatch.mockResolvedValueOnce({ ...off, connected: false });
const { result } = renderHook(() => useWandererWatch('false||'));
await waitFor(() => expect(repo.getWatch).toHaveBeenCalled());
await act(async () => {
await result.current.toggle();
});
expect(repo.setWatch).not.toHaveBeenCalled();
repo.getWatch.mockRejectedValueOnce(new Error('offline'));
const failed = renderHook(() => useWandererWatch('true||'));
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
expect(failed.result.current.available).toBe(false);
});
it('lists the hints and opens the tour one is about', async () => {
repo.getWatch.mockResolvedValue({ ...off, enabled: true, hints: [hint] });
const { result } = renderHook(() => useWandererWatch('true||'));
await waitFor(() => expect(result.current.hints).toEqual([hint]));
act(() => result.current.openHints());
expect(result.current.hintsOpen).toBe(true);
act(() => result.current.openTour(hint));
expect(navigate).toHaveBeenCalledWith('/trips/3?tab=plan&place=7');
expect(result.current.hintsOpen).toBe(false);
act(() => result.current.openHints());
act(() => result.current.closeHints());
expect(result.current.hintsOpen).toBe(false);
});
});
@@ -0,0 +1,115 @@
import type { WandererSourceHintItem, WandererWatch } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n';
import { wandererRepo } from '../../repo/wandererRepo';
import { useToast } from '../shared/Toast';
import { hintWhen } from '../Tours/wanderer/sourceHintParts';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** What the watch last did, as one pill: refused, waiting after errors, last looked, or not yet. */
interface WandererWatchStatus {
tone: 'neutral' | 'warning' | 'danger';
text: string;
/** Why, for the tooltip; null where the text says it all. */
why: string | null;
}
/** The pill for a switched-on watch; null while it is off, since then nothing runs to report on. */
export function watchStatusOf(
watch: WandererWatch | null,
t: Translate,
locale: string,
now = Date.now()
): WandererWatchStatus | null {
if (!watch?.enabled) return null;
if (watch.blocked) return { tone: 'danger', text: t('wanderer.watch.blocked'), why: t('wanderer.watch.blockedHint') };
const pausedUntil = watch.pausedUntil ? new Date(watch.pausedUntil).getTime() : Number.NaN;
if (pausedUntil > now) {
return {
tone: 'warning',
text: t('wanderer.watch.paused', { time: hintWhen(watch.pausedUntil, locale) }),
why: t('wanderer.watch.pausedHint'),
};
}
const lastRun = hintWhen(watch.lastRunAt, locale);
return {
tone: 'neutral',
text: lastRun ? t('wanderer.watch.lastRun', { date: lastRun }) : t('wanderer.watch.neverRun'),
why: null,
};
}
/**
* WAN-02: the background watch in the wanderer card. It loads with the card
* and again whenever `refreshKey` changes, which the connection card bumps on
* connecting, disconnecting, saving a token and a stored test result, so a
* refused token clears once it is fixed. It switches on and off at
* once (there is nothing to save with the form), and opens the tour a hint is
* about. Online-only like the rest of the connection: the watch runs on the
* server and its hints are read from there.
*/
export function useWandererWatch(refreshKey: string) {
const { t, locale } = useTranslation();
const toast = useToast();
const navigate = useNavigate();
const [watch, setWatch] = useState<WandererWatch | null>(null);
const [saving, setSaving] = useState(false);
const [hintsOpen, setHintsOpen] = useState(false);
useEffect(() => {
let cancelled = false;
wandererRepo.getWatch().then(
(next) => {
if (!cancelled) setWatch(next);
},
() => {
// The card above already says when the connection cannot be read; without
// an answer the switch simply stays dimmed.
if (!cancelled) setWatch(null);
}
);
return () => {
cancelled = true;
};
}, [refreshKey]);
const toggle = useCallback(async () => {
if (!watch?.connected || saving) return;
setSaving(true);
try {
const next = await wandererRepo.setWatch(!watch.enabled);
setWatch(next);
toast.success(next.enabled ? t('wanderer.watch.on') : t('wanderer.watch.off'));
} catch {
toast.error(t('wanderer.watch.failed'));
} finally {
setSaving(false);
}
}, [saving, t, toast, watch]);
const openTour = useCallback(
(hint: WandererSourceHintItem) => {
setHintsOpen(false);
navigate(`/trips/${hint.tripId}?tab=plan&place=${hint.placeId}`);
},
[navigate]
);
return {
/** The switch can act: the server knows a connection to watch with. */
available: !!watch?.connected,
enabled: !!watch?.enabled,
saving,
status: watchStatusOf(watch, t, locale),
hints: watch?.hints ?? [],
hintsOpen,
openHints: useCallback(() => setHintsOpen(true), []),
closeHints: useCallback(() => setHintsOpen(false), []),
openTour,
toggle,
};
}
export type WandererWatchController = ReturnType<typeof useWandererWatch>;
@@ -0,0 +1,99 @@
import {
BIKE_PROFILES,
importableTourTypeSchema,
type BikeProfile,
type PaddleBoatType,
type PaddleWaterContext,
} from '@trek/shared';
import { Info } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../i18n';
import { LABEL, Segmented } from '../shared/dialogParts';
import { DialogSection } from '../shared/DialogShell';
import type { GpxImportChoicesState } from './gpxImportChoices';
import { RecordedRideFacts } from './horse/RecordedRideFacts';
import { TourTypeSelect } from './planner/TourTypeSelect';
import { RunProfileField } from './run/RunFields';
import { SkiClassificationFields } from './ski/SkiClassificationFields';
import { TourNotice } from './tourParts';
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
/** Every type a GPX file can come in as: the planner's, and ski, which is only kept (SKI-02). */
const IMPORT_TYPES = importableTourTypeSchema.options;
/** A paddle import's boat and water, side by side, each chosen on purpose. */
function PaddleImportFields({ choices }: { choices: GpxImportChoicesState }) {
const { t } = useTranslation();
const { chosen } = choices;
return (
<>
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.boat')}</span>
<Segmented<PaddleBoatType>
value={(chosen.boat ?? '') as PaddleBoatType}
onChange={choices.setBoat}
label={t('tours.paddle.boat')}
fill
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
/>
</div>
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.water')}</span>
<Segmented<PaddleWaterContext>
value={(chosen.water ?? '') as PaddleWaterContext}
onChange={choices.setWater}
label={t('tours.paddle.water')}
fill
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
/>
</div>
</div>
{(chosen.boat === null || chosen.water === null) && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t('tours.paddle.chooseBoatImport')}
</TourNotice>
)}
</>
);
}
/**
* The preview's "import as" section: the type, and below it what that type
* names: a bike or run profile, a paddle tour's boat and water, a ski track's
* discipline and technique (SKI-02), and for a ride (HOR-02) what the import
* keeps and does not work out.
*/
export function GpxImportTypeSection({ choices }: { choices: GpxImportChoicesState }) {
const { t } = useTranslation();
const typeLabelId = useId();
const { chosen } = choices;
return (
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
<div className="flex flex-col gap-3">
<TourTypeSelect value={chosen.type} onChange={choices.setType} labelId={typeLabelId} types={IMPORT_TYPES} />
{chosen.type === 'bike' && (
<div data-testid="gpx-preview-bike" className="min-w-0">
<span className={LABEL}>{t('tours.bike.profile')}</span>
<Segmented<BikeProfile>
value={chosen.bikeProfile}
onChange={choices.setBikeProfile}
label={t('tours.bike.profile')}
fill
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
/>
</div>
)}
{chosen.type === 'run' && <RunProfileField value={chosen.runProfile} onChange={choices.setRunProfile} />}
{chosen.type === 'kayak' && <PaddleImportFields choices={choices} />}
{chosen.type === 'ski' && <SkiClassificationFields choice={chosen.ski} onChange={choices.setSki} />}
{chosen.type === 'horse' && (
<div data-testid="gpx-preview-horse">
<RecordedRideFacts />
</div>
)}
</div>
</DialogSection>
);
}
@@ -0,0 +1,40 @@
import { AlertTriangle, Loader2, RefreshCw, WifiOff } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import { CardAction, TourNotice } from './tourParts';
/**
* How a card's online read stands while it has no answer to show (a bike
* tour's ways, a hike's SAC grades): still asking, offline, or failed with a
* way to ask again. Nothing while the answer is there, so the card shows it.
*/
export function ReadStatusNotice({
status,
messages,
onRetry,
}: {
status: string;
messages: { loading: string; offline: string; failed: string };
onRetry: () => void;
}) {
const { t } = useTranslation();
if (status === 'loading')
return (
<span role="status" className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{messages.loading}
</span>
);
if (status === 'offline') return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{messages.offline}</TourNotice>;
if (status !== 'failed') return null;
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{messages.failed}</span>
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={onRetry}>
{t('common.errorRetry')}
</CardAction>
</span>
</TourNotice>
);
}
@@ -0,0 +1,36 @@
import type { LucideIcon } from 'lucide-react';
import { Tooltip } from '../shared/Tooltip';
/**
* A frosted badge on the top corner of a tour's round tile, carrying one
* icon: a hike's winter label (HIK-01), a ski track's discipline (SKI-02).
* Its tooltip and accessible name say what the icon stands for.
*/
export function TourCornerMark({
icon: Icon,
label,
size = 15,
testId,
}: {
icon: LucideIcon;
label: string;
/** The badge's diameter in pixels, about half the tile's. */
size?: number;
testId: string;
}) {
return (
<span className="absolute -end-1 -top-1 flex">
<Tooltip label={label}>
<span
data-testid={testId}
role="img"
aria-label={label}
className="grid place-items-center rounded-full bg-surface-card text-info shadow-sm ring-1 ring-inset ring-edge-faint"
style={{ width: size, height: size }}
>
<Icon size={Math.round(size * 0.64)} strokeWidth={2.4} aria-hidden="true" />
</span>
</Tooltip>
</span>
);
}
@@ -35,6 +35,14 @@ describe('RC-01 Tour day-row facts', () => {
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
});
it('HOR-01: a ride with a recorded time shows the horseshoe beside its riding time', () => {
render(<TourDayRowFacts tour={{ ...tour, tour_type: 'horse', max_hiking_difficulty: null }} />);
const time = screen.getByTestId('trip-plan-tour-walking-time');
expect(time.getAttribute('aria-label')).toMatch(/^Riding time/);
expect(time.querySelector('.lucide-horseshoe')).toBeTruthy();
expect(time.querySelector('.lucide-footprints')).toBeNull();
});
it.each([false, true])('renders compact facts with accessible meaning (mobile=%s)', (mobile) => {
render(<TourDayRowFacts tour={tour} mobile={mobile} />);
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
@@ -1,5 +1,5 @@
import type { TourListItem } from '@trek/shared';
import { ArrowUp, CheckCheck, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { importableTourTypeSchema, type TourListItem } from '@trek/shared';
import { ArrowUp, CheckCheck, Clock, Gauge, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { createElement, useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
@@ -8,15 +8,28 @@ import { WhiteBadge } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { paddleCheckFact } from './paddle/paddleCheckModel';
import { runPaceText } from './run/runPaceText';
import { SkiCornerMark } from './ski/skiMarks';
import { tourDayRowIcon } from './tourDayRowIcon';
import { movingTimeLabel } from './tourFacts';
import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation';
import { useWalkProgress } from './walk/walkProgress';
import { walkStaysText } from './walk/walkTimeFields';
import { WinterCornerMark } from './winter/winterMarks';
/** Whether the app names this type: one it plans or keeps (a ski track, SKI-02), not a key only the catalogue holds. */
function knownType(type: string): boolean {
return importableTourTypeSchema.safeParse(type).success;
}
/** The icon the time on the move goes by: a bike tour, a ride on horseback, a run or a paddle by its type's own, any walk by footprints. */
function movingIconType(type: string): string {
return type === 'kayak' || type === 'bike' || type === 'run' || type === 'horse' ? type : 'walk';
}
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
const { t } = useTranslation();
const known = ['hike', 'bike', 'walk', 'kayak'].includes(type);
const known = knownType(type);
return (
<span
data-testid="plan-tour-icon"
@@ -82,18 +95,41 @@ function Fact({
/**
* A tour's tile in the day plan and in its detail: the T-scale grade in a round
* tile, the same tile the tour lists use, so a tour reads alike wherever it
* shows. A GPX import has no grade and shows its type's icon instead.
* shows. A GPX import has no grade and shows its type's icon instead. A hike's
* winter label (HIK-01) or a ski track's discipline (SKI-02) sits on the
* tile's corner, as in the tour lists.
*/
export function TourDayRowAvatar({
tour,
size = 32,
}: {
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'> &
Partial<Pick<TourListItem, 'winter_activity' | 'ski_discipline' | 'ski_technique'>>;
size?: number;
}) {
const avatar = <TourGradeAvatar tour={tour} size={size} />;
const ski = tour.tour_type === 'ski' && !!tour.ski_discipline;
if (!tour.winter_activity && !ski) return avatar;
const markSize = Math.round(size * 0.44);
return (
<span className="relative flex flex-none">
{avatar}
<WinterCornerMark activity={tour.winter_activity} size={markSize} />
<SkiCornerMark tour={tour} size={markSize} />
</span>
);
}
function TourGradeAvatar({
tour,
size,
}: {
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
size: number;
}) {
const { t } = useTranslation();
const level = tour.max_hiking_difficulty;
const known = ['hike', 'bike', 'walk', 'kayak'].includes(tour.tour_type);
const known = knownType(tour.tour_type);
const label =
level != null
? t(`tours.planner.difficulty.t${level}`)
@@ -168,8 +204,11 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
const walking = times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes);
const total = plannedTotalText(times, t);
const staysText = times.walk ? walkStaysText(times.walk, t) : null;
const pace = runPaceText(tour, t);
const timeParts = [
walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null,
// RUN-01: the pace the running time is planned at.
pace ? `${t('tours.run.pace')}: ${pace}` : null,
times.breakMinutes
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
: null,
@@ -231,11 +270,13 @@ export default function TourDayRowFacts({
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
if (times.walkingMinutes != null)
add(
tourDayRowIcon(tour.tour_type === 'kayak' || tour.tour_type === 'bike' ? tour.tour_type : 'walk'),
tourDayRowIcon(movingIconType(tour.tour_type)),
formatPlannedTourDuration(times.walkingMinutes),
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
'trip-plan-tour-walking-time'
);
const pace = runPaceText(tour, t);
if (pace) add(Gauge, pace, t('tours.run.pace'), 'plan-tour-run-pace');
if (times.breakMinutes != null && times.breakMinutes > 0)
add(
Pause,
@@ -304,6 +304,61 @@ describe('TourDetailDialog', () => {
expect(screen.queryByText(/Based on/)).not.toBeInTheDocument()
})
it('HIK-01: names the winter label of a hike in its head and on its tile, and nothing for a hike without one', () => {
const { unmount } = render(<TourDetailDialog {...defaultProps} />)
expect(screen.queryByTestId('tour-detail-winter')).not.toBeInTheDocument()
expect(screen.queryByTestId('tour-winter-mark')).not.toBeInTheDocument()
unmount()
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, winter_activity: 'snowshoe' }} />)
expect(screen.getByTestId('tour-detail-winter')).toHaveTextContent('Snowshoe hike')
expect(screen.getByRole('img', { name: 'Snowshoe hike' })).toBeInTheDocument()
expect(screen.getAllByText('T2').length).toBeGreaterThan(0)
})
it('ACT-01: a recording that is the actual track of a tour names that tour in its head', () => {
seedStore(useTripStore, { places: [buildPlace({ id: 7, name: 'Lake loop' }), place] })
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, has_waypoints: false, origin: 'gpx', activity_of: 7 }} />)
expect(screen.getByTestId('tour-detail-track-of')).toHaveTextContent('Actual track of Lake loop')
})
it('ACT-02: an actual track stands beside the plan the head shows, and only the map detail offers to compare it there', async () => {
const activity = {
id: 4,
assignment_id: null,
day_id: null,
assigned_at: null,
track: { place_id: 20, name: 'Watch export', origin: 'gpx' as const, file_name: 'morning.gpx', imported_at: null },
metrics: {
distance_km: 3,
elevation_gain_m: null,
elevation_loss_m: null,
started_at: null,
ended_at: null,
elapsed_minutes: null,
point_count: 2,
},
geometry: [
[48, 11],
[48, 11.03],
] as [number, number][],
}
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour, waypoints: [], activities: [activity] })
const { unmount } = render(<TourDetailDialog {...defaultProps} desktopNonModal />)
const distance = await screen.findByTestId('tour-activity-compare-distance')
// The plan as the head measures it from the line, not the list's stale 999 km.
expect(distance).toHaveTextContent('2.2 km')
expect(distance).toHaveTextContent('3 km')
expect(screen.getByRole('button', { name: /Compare on map/ })).toBeInTheDocument()
unmount()
render(<TourDetailDialog {...defaultProps} />)
await screen.findByTestId('tour-activity-compare-distance')
expect(screen.queryByRole('button', { name: /Compare on map/ })).not.toBeInTheDocument()
})
it('shows the grade on the tile and no second difficulty scale', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, difficulty: 'easy', max_hiking_difficulty: 1 }} />)
@@ -13,6 +13,7 @@ import {
FileDown,
FileImage,
FileText,
Footprints,
Loader2,
Minus,
Navigation,
@@ -48,9 +49,15 @@ import TrackColorPicker from '../shared/TrackColorPicker';
import { markdownLinkComponents } from '../shared/markdownLink';
import { TourDayRowAvatar } from './TourDayRowFacts';
import { TourGpxExportButton } from './TourGpxExportButton';
import TourActivitySection from './activity/TourActivitySection';
import type { PlanValues } from './activity/tourActivityCompare';
import { TourSaveToCollectionButton } from './collection/TourSaveToCollectionButton';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { TourPackingSection } from './packing/TourPackingSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
import { RunHeadPills } from './run/RunHeadPills';
import { SkiHeadPill } from './ski/skiMarks';
import { tourDayRowIcon } from './tourDayRowIcon';
import { tourDaysOf } from './tourDays';
import { heightsUnknown, movingTimeLabel, withoutHeights } from './tourFacts';
@@ -69,6 +76,7 @@ import { useTourPermissions, type TourPermissionProps } from './useTourPermissio
import { WalkRoutePendingHint } from './walk/WalkRoutePending';
import { WalkStationsCard } from './walk/WalkStations';
import { walkTimeFields } from './walk/walkTimeFields';
import { WinterActivityPill } from './winter/winterMarks';
interface TourDetailDialogProps extends TourPermissionProps {
tour: TourListItem;
@@ -85,6 +93,10 @@ interface TourDetailDialogProps extends TourPermissionProps {
onSourceReimported?: (tour: TourListItem) => Promise<void> | void;
/** P2C-07: save this tour again as a new, independent one. */
onSaveAsNew?: (tour: TourListItem) => Promise<void> | void;
/** ACT-01: a recorded track was assigned, taken off or imported; the lists take in these tours. */
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
/** P2D-XD-01: opens the trip's packing list the tour points at. */
onOpenPacking?: () => void;
onAssignToDay?: (placeId: number, dayId?: number) => void;
onRemoveAssignment?: (dayId: number, assignmentId: number) => void;
onDelete?: () => void;
@@ -208,7 +220,8 @@ function tourTimeFields(
}
/**
* The head's facts as pills: the type, where it came from, the time (its parts
* The head's facts as pills: the type, a hike's winter label, a run's profile and pace or a ski track's
* discipline, where it came from, the time (its parts
* in the tooltip), distance, ascent, descent, and a caution.
*/
function TourHeadPills({
@@ -239,6 +252,10 @@ function TourHeadPills({
? null
: (state.places.find((p) => p.id === tour.derived_from_place_id)?.name ?? null)
);
// ACT-01: the tour this one is the recorded track of.
const documentsName = useTripStore((state) =>
tour.activity_of == null ? null : (state.places.find((p) => p.id === tour.activity_of)?.name ?? null)
);
return (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<span className={HEAD_PILL} style={fs(10.5)}>
@@ -250,6 +267,12 @@ function TourHeadPills({
})}
{t(`tourTypes.${tour.tour_type}`)}
</span>
{/* HIK-01: a hike's winter label, the travellers' own, beside its type. */}
{tour.winter_activity && <WinterActivityPill activity={tour.winter_activity} className={HEAD_PILL} />}
{/* RUN-01: a run's profile and the pace its time is planned at. */}
<RunHeadPills tour={tour} className={HEAD_PILL} />
{/* SKI-02: a ski track's discipline, a cross-country technique inside it. */}
<SkiHeadPill tour={tour} className={HEAD_PILL} />
{/* A paddle tour names its boat and its water, each a badge of its own. */}
{tour.boat_type && (
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-boat">
@@ -275,6 +298,14 @@ function TourHeadPills({
</span>
</Tooltip>
)}
{documentsName && (
<Tooltip label={t('tours.activity.isTrackHint')}>
<span data-testid="tour-detail-track-of" className={`${HEAD_PILL} max-w-[220px]`} style={fs(10.5)}>
<Footprints size={11} strokeWidth={2} className="flex-none text-success" />
<span className="min-w-0 truncate">{t('tours.activity.ofTour', { name: documentsName })}</span>
</span>
</Tooltip>
)}
{shownTime != null && (
<Tooltip label={timeTooltip}>
<span aria-label={timeTooltip} className={HEAD_PILL} style={fs(10.5)}>
@@ -473,6 +504,8 @@ export default function TourDetailDialog({
onFileUpload,
onSourceReimported,
onSaveAsNew,
onToursChanged,
onOpenPacking,
onAssignToDay,
onRemoveAssignment,
onDelete,
@@ -516,6 +549,21 @@ export default function TourDetailDialog({
const times = tourPlannedTimes(tour);
const timeFields = tourTimeFields(times, t, tour);
// The plan as the line measures it now: the head says it, and an actual track is compared with it (ACT-02).
const planDistanceKm = analysis?.distanceKm ?? tour.distance;
const planGain = analysis ? analysis.gain : tour.elevation_gain;
const planLoss = analysis ? analysis.loss : tour.elevation_loss;
const plan = useMemo<PlanValues>(
() => ({
distanceKm: planDistanceKm,
gain: planGain,
loss: planLoss,
minutes: times.plannedTotalMinutes,
minutesAtLeast: times.atLeast,
line: analysis?.routeCoordinates ?? null,
}),
[analysis, planDistanceKm, planGain, planLoss, times.atLeast, times.plannedTotalMinutes]
);
const header = (
<>
@@ -546,11 +594,7 @@ export default function TourDetailDialog({
trackColor={trackColor}
timeFields={timeFields}
times={times}
metrics={{
distanceKm: analysis?.distanceKm ?? tour.distance,
gain: analysis ? analysis.gain : tour.elevation_gain,
loss: analysis ? analysis.loss : tour.elevation_loss,
}}
metrics={{ distanceKm: plan.distanceKm, gain: plan.gain, loss: plan.loss }}
/>
</div>
</>
@@ -626,6 +670,9 @@ export default function TourDetailDialog({
{analysis && place.id > 0 && (
<TourGpxExportButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
)}
{place.id > 0 && (
<TourSaveToCollectionButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
)}
{canEdit && onSaveAsNew && <SaveAsNewButton onSave={() => onSaveAsNew(tour)} />}
<FooterSpacer />
{canEdit && onDelete && (
@@ -699,6 +746,27 @@ export default function TourDetailDialog({
)}
{tour.tour_type === 'kayak' && <PaddleCheckSection tour={tour} />}
{tour.tour_type === 'kayak' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
{/* A tour written here offline has no server copy to assign a recording to until it syncs. */}
{place.id > 0 && (
<TourActivitySection
tripId={place.trip_id}
tour={tour}
days={days}
assignments={assignments}
canEdit={canEdit}
plan={plan}
planColor={trackColor}
mapCompare={desktopNonModal}
onToursChanged={onToursChanged}
/>
)}
<TourPackingSection
tripId={place.trip_id}
tour={tour}
tourName={place.name}
canEdit={canEdit}
onOpenPacking={onOpenPacking}
/>
{description && (
<DialogSection className="flex-none" label={t('places.formDescription')}>
@@ -32,7 +32,7 @@ describe('TourGpxDropZone', () => {
const zone = screen.getByText('workspace').parentElement as HTMLElement;
fireEvent.dragEnter(zone, filesDrag);
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Drop GPX files to import them');
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Drop GPX, KML or KMZ files to import them');
fireEvent.drop(zone, filesDrag);
expect(importer.importFiles).toHaveBeenCalledWith([file]);
@@ -89,7 +89,7 @@ describe('TourGpxDropZone', () => {
<p>workspace</p>
</TourGpxDropZone>
);
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Importing GPX…');
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Importing…');
});
});
@@ -0,0 +1,74 @@
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-003
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
// imported as one, and when a track was recorded shown where it carried times.
import { fireEvent, render, screen } from '@testing-library/react';
import type { TourGpxPreviewTrack } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
import type { TourGpxImport } from './useTourGpxImport';
vi.mock('../Map/MapViewAuto', () => ({ MapViewAuto: () => <div data-testid="gpx-preview-map-stub" /> }));
function track(overrides: Partial<TourGpxPreviewTrack> = {}): TourGpxPreviewTrack {
return {
name: 'Morning',
geometry: [
[47, 11],
[47.01, 11],
],
point_count: 2,
distance_km: 1.1,
elevation_gain_m: null,
elevation_loss_m: null,
started_at: '2026-07-02T06:10:00.000Z',
ended_at: '2026-07-02T07:00:00.000Z',
duplicate: false,
...overrides,
};
}
function gpxImport(tracks: TourGpxPreviewTrack[]): TourGpxImport {
return {
importing: false,
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks }],
confirm: vi.fn(),
cancel: vi.fn(),
pick: vi.fn(),
importFiles: vi.fn(),
} as unknown as TourGpxImport;
}
describe('TourGpxPreviewDialog (ACT-01)', () => {
it('FE-GPX-PREVIEW-001: shows when a track was recorded and how long it took, and nothing for one without times', () => {
const tracks = [track(), track({ name: 'Drawn', started_at: null, ended_at: null })];
render(<TourGpxPreviewDialog gpxImport={gpxImport(tracks)} />);
const recorded = screen.getAllByTestId('gpx-preview-recorded');
expect(recorded).toHaveLength(1);
expect(recorded[0]).toHaveTextContent('50 min');
});
it('FE-GPX-PREVIEW-002: imports as the preset type, a paddle tour with its boat and water', () => {
const current = gpxImport([track()]);
render(
<TourGpxPreviewDialog
gpxImport={current}
initial={{ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' }}
/>
);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' });
});
it('FE-GPX-PREVIEW-003: a city walk is imported as a city walk, and without a preset as a hike', () => {
const walk = gpxImport([track()]);
const { unmount } = render(<TourGpxPreviewDialog gpxImport={walk} initial={{ tour_type: 'walk' }} />);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' });
unmount();
const hike = gpxImport([track()]);
render(<TourGpxPreviewDialog gpxImport={hike} />);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' });
});
});
@@ -1,13 +1,6 @@
import {
BIKE_PROFILES,
type BikeProfile,
type PaddleBoatType,
type PaddleWaterContext,
type PlannableTourType,
type TourGpxImportFields,
} from '@trek/shared';
import { AlertTriangle, FileDown, Info } from 'lucide-react';
import { useId, useMemo, useState } from 'react';
import type { TourFileLeftOut, TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, Ban, Clock, FileDown, Layers, MapPin, Shapes } from 'lucide-react';
import { useId, useMemo } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
import { useTranslation } from '../../i18n';
@@ -15,7 +8,6 @@ import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { MapViewAuto } from '../Map/MapViewAuto';
import { SoftPill } from '../Planner/planParts';
import { LABEL, Segmented } from '../shared/dialogParts';
import {
DialogButton,
DialogFooter,
@@ -27,17 +19,45 @@ import {
fs,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TourTypeSelect } from './planner/TourTypeSelect';
import { TourNotice } from './tourParts';
import { recordedDay, recordedMinutes } from './activity/tourActivityModel';
import { importFields, importMissing, useGpxImportChoices } from './gpxImportChoices';
import { GpxImportTypeSection } from './GpxImportTypeSection';
import { InfoDot, TourNotice } from './tourParts';
import { formatPlannedTourDuration } from './tourPresentation';
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
const BADGE =
'inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 font-geist tabular-nums leading-[20px] text-content-secondary';
/** One track of the file as a row: its name, and what it measures as badges; a track the trip holds says so. */
function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile['tracks'][number] }) {
/** ACT-01: when a track was recorded and how long it took, where every point carried a time. */
function RecordedBadge({ track }: { track: GpxPreviewFile['tracks'][number] }) {
const { locale } = useTranslation();
const minutes = recordedMinutes(track.started_at, track.ended_at);
const day = recordedDay(track.started_at, locale);
if (minutes === null || !day) return null;
return (
<span className={`${BADGE} gap-1`} data-testid="gpx-preview-recorded">
<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />
{day}
<span className="text-content-faint">{formatPlannedTourDuration(minutes)}</span>
</span>
);
}
/**
* One track of the file as a row: its number, its name, and what it measures
* as badges; a track the trip holds says so. The number keeps the lines of a
* file apart, they are imported one tour each and never joined.
*/
function PreviewTrackRow({
name,
number,
track,
}: {
name: string;
number: number;
track: GpxPreviewFile['tracks'][number];
}) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const heights = track.elevation_gain_m != null && track.elevation_loss_m != null;
@@ -48,12 +68,19 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[
className={`flex min-w-0 flex-col gap-1.5 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 ${track.duplicate ? 'opacity-60' : ''}`}
>
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="grid h-6 w-6 flex-none place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content ring-2 ring-surface-card"
style={fs(11)}
>
{number}
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
{track.name || name}
</span>
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
</div>
<div className="flex flex-wrap items-center gap-1.5" style={fs(11)}>
<div className="flex flex-wrap items-center gap-1.5 ps-8" style={fs(11)}>
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
{heights ? (
<>
@@ -64,32 +91,97 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[
<span className={BADGE}>{t('tours.import.previewNoHeights')}</span>
)}
<span className={BADGE}>{t('tours.import.previewPoints', { count: track.point_count })}</span>
<RecordedBadge track={track} />
</div>
</li>
);
}
/** What all the files held besides their lines, summed by kind; null when they held nothing else. */
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
const sum = { points: 0, areas: 0, other: 0 };
for (const { leftOut } of files) {
if (!leftOut) continue;
sum.points += leftOut.points;
sum.areas += leftOut.areas;
sum.other += leftOut.other;
}
return sum.points + sum.areas + sum.other > 0 ? sum : null;
}
/**
* IO-02: what a KML or KMZ file held besides its lines, named before the
* import so nothing goes missing unseen: a dashed card with each kind as a
* counted badge, and why in the info dot.
*/
function PreviewLeftOut({ files }: { files: readonly GpxPreviewFile[] }) {
const { t } = useTranslation();
const leftOut = leftOutOf(files);
if (!leftOut) return null;
const kinds = [
{ key: 'points', count: leftOut.points, icon: MapPin, labelKey: 'tours.import.leftOutPoints' },
{ key: 'areas', count: leftOut.areas, icon: Shapes, labelKey: 'tours.import.leftOutAreas' },
{ key: 'other', count: leftOut.other, icon: Layers, labelKey: 'tours.import.leftOutOther' },
].filter(({ count }) => count > 0);
return (
<div
data-testid="gpx-preview-left-out"
className="mt-2 flex flex-wrap items-center gap-1.5 rounded-[10px] border border-dashed border-edge px-3 py-2"
>
<Ban size={13} strokeWidth={2} className="flex-none text-content-faint" aria-hidden="true" />
<span className="me-0.5 font-semibold text-content-muted" style={fs(12, 'body')}>
{t('tours.import.previewLeftOut')}
</span>
{kinds.map(({ key, count, icon: Icon, labelKey }) => (
<SoftPill key={key} icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{t(labelKey, { count })}
</SoftPill>
))}
<span className="ms-auto">
<InfoDot label={t('tours.import.previewLeftOut')} description={t('tours.import.previewLeftOutHint')} />
</span>
</div>
);
}
/**
* PAD-02: a GPX file shown before anything is stored. The whole course on a
* map, every track with its measures, the tracks the trip already holds named
* as skipped, and the type to import them as; a paddle import names its boat
* and water, as a planned paddle tour does. Closing it stores nothing.
* and water, as a planned paddle tour does, a ski import its discipline
* (SKI-02); a ride (HOR-02) says before anything is stored that its line stays
* as recorded, gets no riding time and is not checked for access. Closing it
* stores nothing. `initial` presets the type, as a recording imported for a
* tour takes that tour's (ACT-01); left out, it is a hike. A KML or KMZ file
* is shown the same way, with what it holds besides (IO-02).
*/
export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpxImport }) {
export default function TourGpxPreviewDialog({
gpxImport,
initial,
}: {
gpxImport: TourGpxImport;
initial?: TourGpxImportFields;
}) {
const { preview } = gpxImport;
if (!preview) return null;
return <PreviewDialog gpxImport={gpxImport} files={preview} />;
return <PreviewDialog gpxImport={gpxImport} files={preview} initial={initial ?? HIKE} />;
}
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
const HIKE: TourGpxImportFields = { tour_type: 'hike' };
function PreviewDialog({
gpxImport,
files,
initial,
}: {
gpxImport: TourGpxImport;
files: GpxPreviewFile[];
initial: TourGpxImportFields;
}) {
const { t } = useTranslation();
const labelId = useId();
const typeLabelId = useId();
const tileUrl = useTileUrl(OFM_POSITRON);
const [type, setType] = useState<PlannableTourType>('hike');
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
const [water, setWater] = useState<PaddleWaterContext | null>(null);
const [bikeProfile, setBikeProfile] = useState<BikeProfile>('touring');
const choices = useGpxImportChoices(initial);
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
@@ -101,14 +193,8 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
[files]
);
const focusPoints = useMemo(() => lines.flat(), [lines]);
const paddleIncomplete = type === 'kayak' && (boat === null || water === null);
const canImport = fresh > 0 && !paddleIncomplete && !gpxImport.importing;
const fields: TourGpxImportFields =
type === 'kayak' && boat && water
? { tour_type: 'kayak', boat_type: boat, water_context: water }
: type === 'bike'
? { tour_type: 'bike', bike_profile: bikeProfile }
: { tour_type: 'hike' };
const canImport = fresh > 0 && importMissing(choices.chosen) === null && !gpxImport.importing;
const fields = importFields(choices.chosen);
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
return (
@@ -168,9 +254,10 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
<DialogSection label={t('tours.import.previewTracks', { count: tracks.length })}>
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
{tracks.map(({ file, track }, index) => (
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} track={track} />
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} number={index + 1} track={track} />
))}
</ul>
<PreviewLeftOut files={files} />
{fresh === 0 && (
<div className="mt-2">
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
@@ -180,52 +267,7 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
)}
</DialogSection>
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
<div className="flex flex-col gap-3">
<TourTypeSelect value={type} onChange={setType} labelId={typeLabelId} />
{type === 'bike' && (
<div data-testid="gpx-preview-bike" className="min-w-0">
<span className={LABEL}>{t('tours.bike.profile')}</span>
<Segmented<BikeProfile>
value={bikeProfile}
onChange={setBikeProfile}
label={t('tours.bike.profile')}
fill
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
/>
</div>
)}
{type === 'kayak' && (
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.boat')}</span>
<Segmented<PaddleBoatType>
value={(boat ?? '') as PaddleBoatType}
onChange={setBoat}
label={t('tours.paddle.boat')}
fill
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
/>
</div>
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.water')}</span>
<Segmented<PaddleWaterContext>
value={(water ?? '') as PaddleWaterContext}
onChange={setWater}
label={t('tours.paddle.water')}
fill
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
/>
</div>
</div>
)}
{paddleIncomplete && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t('tours.paddle.chooseBoatImport')}
</TourNotice>
)}
</div>
</DialogSection>
<GpxImportTypeSection choices={choices} />
</DialogShell>
);
}
+13 -2
View File
@@ -3,10 +3,11 @@ import { useId, type ReactNode } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import { fs } from '../shared/DialogShell'
import { TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts'
import { FoldButton, TourActivityBadge, TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts'
import type { Day } from '../../types'
import { endPlaceDrag, startPlaceDrag } from '../Map/markerDrag'
import { TourDayRowPills } from './TourDayRowFacts'
import { TourStagesBadge } from './stages/StageParts'
import { useTourDays } from './tourDays'
import { Tooltip } from '../shared/Tooltip'
@@ -27,6 +28,8 @@ interface TourListRowProps {
trackColor?: string
/** PR1-07: the person's switch for this tour's line on the trip map. */
mapToggle?: { hidden: boolean; onToggle: () => void; showLabel: string; hideLabel: string }
/** MDT-02: a multi-day tour unfolds its stages below the row; `controls` is the id of what unfolds. */
fold?: { expanded: boolean; onToggle: () => void; label: string; controls: string }
}
/** The eye beside a row: shown on hover like the other actions, and always while the tour is hidden. */
@@ -76,6 +79,8 @@ function TourRowBody({ tour, nameId, tourDays, dimmed }: {
className="-mx-0.5 mt-0.5 flex h-6 min-w-0 flex-wrap content-start gap-x-1.5 gap-y-2 overflow-hidden px-0.5 pt-0.5"
>
<TourCautionBadge tour={tour} />
<TourActivityBadge tour={tour} />
<TourStagesBadge tour={tour} />
<TourDayRowPills tour={tour} compact />
<TourDaysBadge tourDays={tourDays} />
</div>
@@ -115,7 +120,7 @@ function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps,
export default function TourListRow({ tour,
days = NO_DAYS,
selected = false, disabled = false,
draggableToDay = false, onSelect, action, children, trackColor, mapToggle }: TourListRowProps) {
draggableToDay = false, onSelect, action, children, trackColor, mapToggle, fold }: TourListRowProps) {
const nameId = useId()
const tourDays = useTourDays(tour.place_id, days)
const draggable = draggableToDay && !disabled
@@ -149,6 +154,12 @@ export default function TourListRow({ tour,
)}
<TourRowBody tour={tour} nameId={nameId} tourDays={tourDays} dimmed={!!mapToggle?.hidden} />
<TourRowActions action={action} mapToggle={mapToggle} selected={selected} />
{fold && (
// Always in sight, unlike the hover actions: the stages are part of what the row is.
<span role="presentation" className="flex-none" onClick={event => event.stopPropagation()}>
<FoldButton expanded={fold.expanded} onToggle={fold.onToggle} controls={fold.controls} label={fold.label} />
</span>
)}
</div>
{children}
</li>
@@ -5,6 +5,7 @@ import type { GpxConversion } from './conversion/useGpxConversion';
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
import type { TourPlannerController } from './planner/useTourPlanner';
import type { SkiClassification } from './ski/useSkiClassification';
import type { RecordedTrack } from './useRecordedTrack';
import type { WandererBrowser } from './wanderer/useWandererBrowser';
@@ -63,4 +64,12 @@ describe('TourWorkspaceRail', () => {
expect(seen.rail[seen.rail.length - 1]?.trackPaddlePlan).toBe(trackPaddlePlan);
expect(seen.rail[seen.rail.length - 1]?.paddleChecklist).toBe(paddleChecklist);
});
it('SKI-02: hands the classification of the ski track in view on to the editor rail', () => {
const skiClassification = { tour: null } as unknown as SkiClassification;
const parts = { ...workspace(wanderer({})), skiClassification };
seen.rail.length = 0;
render(<TourWorkspaceRail workspace={parts} canEdit canAssign />);
expect(seen.rail[seen.rail.length - 1]?.skiClassification).toBe(skiClassification);
});
});
@@ -3,6 +3,7 @@ import type { PaddleChecklist } from './paddle/usePaddleChecklist';
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
import { TourPlannerRail } from './planner/TourPlannerPanels';
import type { TourPlannerController } from './planner/useTourPlanner';
import type { SkiClassification } from './ski/useSkiClassification';
import type { RecordedTrack } from './useRecordedTrack';
import WandererBrowserRail from './wanderer/WandererBrowserRail';
import type { WandererBrowser } from './wanderer/useWandererBrowser';
@@ -17,6 +18,8 @@ export interface TourWorkspaceRailParts {
trackPaddlePlan?: TrackPaddlePlan;
/** The obstacle checklist of the paddle tour open in the planner (PAD-06). */
paddleChecklist?: PaddleChecklist;
/** The classification of the ski track in view (SKI-02). */
skiClassification?: SkiClassification;
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
tourPlaceIds?: ReadonlySet<number>;
}
@@ -38,6 +41,7 @@ export default function TourWorkspaceRail({
recordedTrack,
trackPaddlePlan,
paddleChecklist,
skiClassification,
tourPlaceIds,
} = workspace;
return wanderer.open ? (
@@ -51,6 +55,7 @@ export default function TourWorkspaceRail({
recordedTrack={recordedTrack}
trackPaddlePlan={trackPaddlePlan}
paddleChecklist={paddleChecklist}
skiClassification={skiClassification}
tourPlaceIds={tourPlaceIds}
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
/>
+154 -10
View File
@@ -241,7 +241,7 @@ describe('ToursSidebar', () => {
/>)
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx')
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [file])).toHaveAttribute('accept', '.gpx,.kml,.kmz')
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
// Only the places are read again, not the whole trip: that would rebuild the workspace.
@@ -269,7 +269,7 @@ describe('ToursSidebar', () => {
onSelectTour={vi.fn()}
/>)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
])
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
@@ -288,7 +288,7 @@ describe('ToursSidebar', () => {
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [
new File(['<gpx/>'], 'empty.gpx', { type: 'application/gpx+xml' }),
new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' }),
])
@@ -296,7 +296,7 @@ describe('ToursSidebar', () => {
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx'))
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported from GPX'))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
expect(importCall).toHaveBeenCalledOnce()
expect(toast.info).toHaveBeenCalledWith('Imported with caution — elevation/metrics incomplete')
vi.restoreAllMocks()
@@ -308,10 +308,10 @@ describe('ToursSidebar', () => {
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveTextContent('GPX preview')
expect(dialog).toHaveTextContent('Import preview')
expect(dialog).toHaveTextContent('ridge.gpx')
expect(screen.getAllByTestId('gpx-preview-track')).toHaveLength(2)
expect(screen.getAllByTestId('gpx-preview-track')[1]).toHaveTextContent('Already in the trip')
@@ -330,7 +330,7 @@ describe('ToursSidebar', () => {
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
expect(await screen.findByText('Every track is already in the trip, so there is nothing to import.')).toBeInTheDocument()
expect(screen.getByTestId('gpx-preview-import')).toBeDisabled()
vi.restoreAllMocks()
@@ -344,7 +344,7 @@ describe('ToursSidebar', () => {
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<gpx/>'], 'lake.gpx', { type: 'application/gpx+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [file])
await screen.findByRole('dialog')
// A file without heights says so instead of showing zero.
expect(screen.getByTestId('gpx-preview-track')).toHaveTextContent('No heights in the file')
@@ -363,21 +363,165 @@ describe('ToursSidebar', () => {
vi.restoreAllMocks()
})
it('SKI-02: a ski import waits for its discipline, and for a cross-country technique, then sends them along', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<gpx/>'], 'loipe.gpx', { type: 'application/gpx+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
await screen.findByRole('dialog')
fireEvent.click(screen.getByTestId('tour-type-select'))
fireEvent.click(screen.getByTestId('tour-type-option-ski'))
const confirm = screen.getByTestId('gpx-preview-import')
expect(confirm).toBeDisabled()
expect(screen.getByText('Choose a discipline to import the track as ski.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cross-country' }))
expect(confirm).toBeDisabled()
expect(screen.getByText('Choose the technique, or Unknown.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Unknown' }))
expect(confirm).toBeEnabled()
fireEvent.click(confirm)
await waitFor(() =>
expect(importCall).toHaveBeenCalledWith(1, file, {
tour_type: 'ski',
ski_discipline: 'cross_country',
ski_technique: 'unknown',
})
)
vi.restoreAllMocks()
})
it('RUN-01: a run import names its profile, on purpose, and sends no pace', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<gpx/>'], 'laps.gpx', { type: 'application/gpx+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
await screen.findByRole('dialog')
fireEvent.click(screen.getByTestId('tour-type-select'))
fireEvent.click(screen.getByTestId('tour-type-option-run'))
expect(screen.getByRole('button', { name: 'Running' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByTestId('gpx-preview-import'))
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'run', run_profile: 'running' }))
vi.restoreAllMocks()
})
it('HOR-01: a recorded ride imports as a ride, with nothing of another type', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<gpx/>'], 'ride.gpx', { type: 'application/gpx+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
await screen.findByRole('dialog')
fireEvent.click(screen.getByTestId('tour-type-select'))
fireEvent.click(screen.getByTestId('tour-type-option-horse'))
fireEvent.click(screen.getByTestId('gpx-preview-import'))
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'horse' }))
vi.restoreAllMocks()
})
it('RC-04: refuses files that are not GPX before uploading anything', async () => {
const importCall = vi.spyOn(tourGpxApi, 'import')
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [
new File(['x'], 'notes.txt', { type: 'text/plain' }),
])
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Only GPX files can be imported as tours.'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Only GPX, KML and KMZ files can be imported as tours.'))
expect(importCall).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('IO-02: previews a KML file like a GPX file, numbers its lines and names what it leaves out, then imports it', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({
tracks: [previewTrack({ name: 'Coast (1/2)' }), previewTrack({ name: 'Coast (2/2)', elevation_gain_m: null, elevation_loss_m: null })],
left_out: { points: 3, areas: 1, other: 0 },
})
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<kml/>'], 'coast.kml', { type: 'application/vnd.google-earth.kml+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [file])
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveTextContent('Import preview')
const rows = screen.getAllByTestId('gpx-preview-track')
expect(rows.map((row) => row.textContent?.slice(0, 1))).toEqual(['1', '2'])
expect(rows[1]).toHaveTextContent('No heights in the file')
const leftOut = screen.getByTestId('gpx-preview-left-out')
expect(leftOut).toHaveTextContent('Not imported')
expect(leftOut).toHaveTextContent('3 pins')
expect(leftOut).toHaveTextContent('1 area')
expect(leftOut).not.toHaveTextContent('other item')
expect(within(leftOut).getByRole('button', { name: 'Not imported' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Import 2 tours' }))
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
vi.restoreAllMocks()
})
it('IO-02: a GPX preview counts nothing it leaves out, so it shows no such card', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
await screen.findByRole('dialog')
expect(screen.queryByTestId('gpx-preview-left-out')).toBeNull()
vi.restoreAllMocks()
})
it('IO-02: names each file the server could not read by the reason it gave, and opens no preview for them', async () => {
const refusal = (code: string) => ({ response: { data: { error: 'refused', code } } })
vi.spyOn(tourGpxApi, 'preview')
.mockRejectedValueOnce(refusal('malformed'))
.mockRejectedValueOnce(refusal('archive_without_kml'))
.mockRejectedValueOnce(refusal('archive_too_large'))
.mockRejectedValueOnce(refusal('no_track'))
.mockRejectedValueOnce(refusal('something_else'))
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [
new File(['x'], 'broken.kml'),
new File(['x'], 'photos.kmz'),
new File(['x'], 'huge.KMZ'),
new File(['x'], 'pins.kml'),
new File(['x'], 'odd.kml'),
])
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(5))
expect(toast.error.mock.calls.map(([message]) => message)).toEqual([
'Could not read broken.kml: the file is damaged or not GPX, KML or KMZ',
'No KML file found in photos.kmz',
'huge.KMZ is too large once unpacked',
'No track or route found in pins.kml',
'Could not import odd.kml',
])
expect(screen.queryByRole('dialog')).toBeNull()
vi.restoreAllMocks()
})
it('RC-02: names the days a tour is on in one badge, in the order of the trip', () => {
const days = [
{ id: 7, trip_id: 1, day_number: 1, title: null, date: null },
@@ -0,0 +1,27 @@
// FE-ACT-COMPARE-LEGEND-001
// ACT-02 over the plan map, rendered without the translation provider so
// every text reads as its key: the legend names both lines, and closing it
// ends the comparison.
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import ActivityCompareLegend from './ActivityCompareLegend';
beforeEach(() => {
useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 });
});
describe('ActivityCompareLegend (ACT-02)', () => {
it('FE-ACT-COMPARE-LEGEND-001: names the plan and the actual track, and ends the comparison when closed', () => {
const legend = { planName: 'Ridge', planColor: 'plan-paint', trackName: 'Watch export' };
render(<ActivityCompareLegend legend={legend} />);
const card = screen.getByTestId('activity-compare-legend');
expect(card).toHaveTextContent('tours.activity.compare.plan');
expect(card).toHaveTextContent('Ridge');
expect(card).toHaveTextContent('tours.activity.badge');
expect(card).toHaveTextContent('Watch export');
fireEvent.click(screen.getByRole('button', { name: 'tours.activity.compare.hide' }));
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
});
});
@@ -0,0 +1,74 @@
import { GitCompareArrows, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
import { Eyebrow } from '../../Planner/bookings/bookingParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { ACTUAL_TRACK_COLOR, LineSwatch } from './activityCompareParts';
import type { ActivityCompareLegend as Legend } from './useActivityCompareMap';
/** One line of the legend: how it is drawn, what it is, and whose it is. */
function LegendRow({ swatch, label, name }: { swatch: ReactNode; label: string; name: string }) {
return (
<li className="flex min-w-0 items-center gap-2">
<span className="grid h-6 w-8 flex-none place-items-center rounded-full bg-surface-card shadow-sm ring-1 ring-inset ring-edge-faint">
{swatch}
</span>
<span className="flex min-w-0 flex-col">
<Eyebrow>{label}</Eyebrow>
<span className="truncate font-semibold text-content" style={fs(12, 'body')}>
{name}
</span>
</span>
</li>
);
}
/**
* ACT-02 over the plan map: what its two lines are while a tour is compared
* with its actual track, in the frosted look of the map's cards. Closing it
* ends the comparison; it changes nothing else.
*/
export default function ActivityCompareLegend({ legend }: { legend: Legend }) {
const { t } = useTranslation();
const close = t('tours.activity.compare.hide');
return (
<aside
data-testid="activity-compare-legend"
aria-label={t('tours.activity.compare.title')}
className="pointer-events-auto w-[240px] rounded-2xl p-3"
style={MAP_CARD_GLASS}
>
<div className="mb-2 flex items-center gap-1.5">
<GitCompareArrows size={13} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
{t('tours.activity.compare.title')}
</span>
<Tooltip label={close}>
<button
type="button"
onClick={() => useTourActivityCompareStore.getState().clear()}
aria-label={close}
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<X size={13} strokeWidth={2.2} />
</button>
</Tooltip>
</div>
<ul className="m-0 flex list-none flex-col gap-2 p-0">
<LegendRow
swatch={<LineSwatch color={legend.planColor} />}
label={t('tours.activity.compare.plan')}
name={legend.planName}
/>
<LegendRow
swatch={<LineSwatch color={ACTUAL_TRACK_COLOR} dotted />}
label={t('tours.activity.badge')}
name={legend.trackName}
/>
</ul>
</aside>
);
}
@@ -0,0 +1,23 @@
// FE-ACT-BADGE-001
// ACT-01 on a tour's row: a tour with an actual track and a recording that is
// the actual track of a tour each say so with an icon, the tooltip naming which.
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { TourActivityBadge } from '../tourParts';
describe('TourActivityBadge (ACT-01)', () => {
it('FE-ACT-BADGE-001: marks a tour with an actual track and a recording that is one, nothing else', () => {
const { rerender } = render(
<TourActivityBadge tour={{ activity_tracks: [{ id: 1, track_place_id: 9, assignment_id: null }] }} />
);
expect(screen.getByTestId('tour-activity-badge')).toHaveAttribute('aria-label', 'tours.activity.badgeHint');
rerender(<TourActivityBadge tour={{ activity_tracks: [], activity_of: 4 }} />);
expect(screen.getByTestId('tour-activity-badge')).toHaveAttribute('aria-label', 'tours.activity.isTrackHint');
rerender(<TourActivityBadge tour={{ activity_tracks: [], activity_of: null }} />);
expect(screen.queryByTestId('tour-activity-badge')).toBeNull();
rerender(<TourActivityBadge tour={{}} />);
expect(screen.queryByTestId('tour-activity-badge')).toBeNull();
});
});
@@ -0,0 +1,142 @@
// FE-ACT-COMPARE-VIEW-001 to FE-ACT-COMPARE-VIEW-006
// ACT-02 in an actual track's card, rendered without the translation
// provider so every text reads as its key: plan and track side by side under
// their own names, the difference only where both are known, a missing value
// named as missing, the deviation from the planned line, and the map switch.
import { fireEvent, render, screen, within } from '@testing-library/react';
import type { TourActivity } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import TourActivityCompare, { formatDifference } from './TourActivityCompare';
import type { PlanValues } from './tourActivityCompare';
const plan: PlanValues = {
distanceKm: 2,
gain: 120,
loss: null,
minutes: 180,
minutesAtLeast: false,
line: [
[47, 11],
[47.01, 11],
[47.02, 11],
],
};
function activity(overrides: Partial<TourActivity['metrics']> = {}): TourActivity {
return {
id: 4,
assignment_id: 11,
day_id: 1,
assigned_at: null,
track: { place_id: 20, name: 'Watch export', origin: 'gpx', file_name: 'morning.gpx', imported_at: null },
metrics: {
distance_km: 2.5,
elevation_gain_m: 120,
elevation_loss_m: 90,
started_at: '2026-07-02T06:00:00Z',
ended_at: '2026-07-02T09:15:00Z',
elapsed_minutes: 195,
point_count: 3,
...overrides,
},
geometry: [
[47, 11, 600],
[47.012, 11.01, 650],
[47.02, 11, 700],
],
};
}
const row = (key: string) => screen.getByTestId(`tour-activity-compare-${key}`);
function show(props: Partial<Parameters<typeof TourActivityCompare>[0]> = {}) {
return render(
<TourActivityCompare activity={activity()} plan={plan} planColor="plan-paint" unit="metric" {...props} />
);
}
describe('TourActivityCompare (ACT-02)', () => {
it('FE-ACT-COMPARE-VIEW-001: names plan, actual and difference, and sets each measure of both side by side', () => {
show();
const table = screen.getByTestId('tour-activity-compare');
expect(within(table).getByText('tours.activity.compare.plan')).toBeInTheDocument();
expect(within(table).getByText('tours.activity.badge')).toBeInTheDocument();
expect(within(table).getByText('tours.activity.compare.difference')).toBeInTheDocument();
expect(row('distance')).toHaveTextContent('2 km');
expect(row('distance')).toHaveTextContent('2.5 km');
expect(row('distance')).toHaveTextContent('+500 m');
expect(row('ascent')).toHaveTextContent('±0 m');
expect(row('time')).toHaveTextContent('3 h');
expect(row('time')).toHaveTextContent('3 h 15 min');
expect(row('time')).toHaveTextContent('+15 min');
});
it('FE-ACT-COMPARE-VIEW-002: a value a side does not carry is named missing, and nothing is set between them', () => {
show({ activity: activity({ elapsed_minutes: null }), plan: { ...plan, gain: null } });
expect(row('ascent')).toHaveTextContent('tours.activity.compare.notInPlan');
expect(row('descent')).toHaveTextContent('tours.activity.compare.notInPlan');
expect(row('time')).toHaveTextContent('tours.activity.noTime');
expect(within(row('time')).queryByText(/min$/)).toBeNull();
show({ activity: activity({ elevation_loss_m: null }), plan: { ...plan, loss: 80 } });
expect(screen.getAllByTestId('tour-activity-compare-descent')[1]).toHaveTextContent(
'tours.activity.compare.notInTrack'
);
});
it('FE-ACT-COMPARE-VIEW-003: a planned total that is only a floor reads "at least" and is not subtracted from', () => {
show({ plan: { ...plan, minutesAtLeast: true } });
expect(row('time')).toHaveTextContent('≥ 3 h');
expect(row('time')).not.toHaveTextContent('+15 min');
});
it('FE-ACT-COMPARE-VIEW-004: measures how far the track left the planned line, or says there is no line', () => {
const { unmount } = show();
expect(screen.getByText('tours.activity.compare.farthest')).toBeInTheDocument();
expect(screen.getByText('tours.activity.compare.offPlan')).toBeInTheDocument();
expect(screen.getByText('tours.activity.compare.offPlanValue')).toBeInTheDocument();
unmount();
show({ plan: { ...plan, line: null } });
expect(screen.queryByText('tours.activity.compare.farthest')).toBeNull();
expect(screen.getByText('tours.activity.compare.noLine')).toBeInTheDocument();
});
it('FE-ACT-COMPARE-VIEW-005: the map switch draws and ends the comparison, and says which it is', () => {
const onToggle = vi.fn();
const { rerender } = show({ map: { shown: false, onToggle } });
const button = screen.getByRole('button', { name: /tours.activity.compare.show/ });
expect(button).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(button);
expect(onToggle).toHaveBeenCalledTimes(1);
rerender(
<TourActivityCompare
activity={activity()}
plan={plan}
planColor="plan-paint"
unit="metric"
map={{ shown: true, onToggle }}
/>
);
expect(screen.getByRole('button', { name: /tours.activity.compare.hide/ })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-ACT-COMPARE-VIEW-006: no map switch without a plan map beside the detail, or without a line to draw', () => {
const { unmount } = show();
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
unmount();
show({ activity: { ...activity(), geometry: [] }, map: { shown: false, onToggle: vi.fn() } });
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
});
});
describe('formatDifference (ACT-02)', () => {
it('signs a difference, calls one that rounds to nothing even, and keeps the unit shown', () => {
expect(formatDifference('distance', -1.25, 'metric')).toBe('-1.3 km');
expect(formatDifference('descent', 0.2, 'metric')).toBe('±0 m');
expect(formatDifference('ascent', 30, 'imperial')).toBe('+98 ft');
expect(formatDifference('time', -45, 'metric')).toBe('-45 min');
});
});
@@ -0,0 +1,231 @@
import type { TourActivity } from '@trek/shared';
import { Equal, Map as MapIcon, TrendingDown, TrendingUp } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BOX, Eyebrow } from '../../Planner/bookings/bookingParts';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, TourFactFields, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { ACTUAL_TRACK_COLOR, LineSwatch } from './activityCompareParts';
import {
compareRows,
OFF_PLAN_M,
planDeviation,
type CompareKey,
type CompareRow,
type PlanValues,
} from './tourActivityCompare';
type Translate = (key: string, params?: Record<string, unknown>) => string;
const ROW_LABELS: Record<CompareKey, string> = {
distance: 'tours.detail.distance',
ascent: 'tours.detail.ascent',
descent: 'tours.detail.descent',
time: 'tours.activity.compare.time',
};
function formatValue(key: CompareKey, value: number, unit: DistanceUnit): string {
if (key === 'distance') return formatDistance(value, unit);
if (key === 'time') return formatPlannedTourDuration(value);
return formatElevation(value, unit);
}
/** A difference as it reads: signed, or "±" where it rounds to nothing in the unit shown. */
export function formatDifference(key: CompareKey, difference: number, unit: DistanceUnit): string {
const size = formatValue(key, Math.abs(difference), unit);
if (size === formatValue(key, 0, unit)) return `±${size}`;
return `${difference > 0 ? '+' : '-'}${size}`;
}
/** One value of one side: the number, or a word saying that side does not carry it. */
function ValueCell({
text,
missing,
hint,
strong = false,
}: {
text: string;
missing: boolean;
hint?: string;
strong?: boolean;
}) {
const look = missing
? 'italic text-content-faint'
: strong
? 'font-semibold text-content'
: 'font-medium text-content-secondary';
return (
<td className="px-2 py-1.5 text-end tabular-nums">
<Tooltip label={hint ?? ''} disabled={!hint}>
<span className={`inline-flex ${look}`}>{text}</span>
</Tooltip>
</td>
);
}
/** The plan's value; a total that leaves a stay out reads "at least", as the head says it. */
function planText(row: CompareRow, plan: PlanValues, unit: DistanceUnit, t: Translate): string {
if (row.plan === null) return t('tours.activity.compare.notInPlan');
const text = formatValue(row.key, row.plan, unit);
return row.key === 'time' && plan.minutesAtLeast ? `≥ ${text}` : text;
}
/** The track's value; a time is said to be measured, or the time stamps to be missing. */
function actualCell(row: CompareRow, unit: DistanceUnit, t: Translate): { text: string; hint?: string } {
if (row.key === 'time') {
return row.actual === null
? { text: t('tours.activity.noTime'), hint: t('tours.activity.noTimeHint') }
: { text: formatValue('time', row.actual, unit), hint: t('tours.activity.timeHint') };
}
return {
text: row.actual === null ? t('tours.activity.compare.notInTrack') : formatValue(row.key, row.actual, unit),
};
}
/** The difference as a small badge whose arrow says which way; empty where the two cannot be compared. */
function DifferenceCell({ row, unit }: { row: CompareRow; unit: DistanceUnit }) {
if (row.difference === null) return <td className="px-2 py-1.5" />;
const text = formatDifference(row.key, row.difference, unit);
const Icon = text.startsWith('±') ? Equal : row.difference > 0 ? TrendingUp : TrendingDown;
return (
<td className="px-2 py-1.5 text-end">
<SoftPill
className="tabular-nums"
icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
>
{text}
</SoftPill>
</td>
);
}
/** A column head: its name with the line it is drawn as on the map. */
function ColumnHead({ label, swatch }: { label: string; swatch?: ReactNode }) {
return (
<th scope="col" className="px-2 py-1.5 text-end font-normal">
<span className="inline-flex items-center gap-1.5">
{swatch}
<Eyebrow>{label}</Eyebrow>
</span>
</th>
);
}
/** The four measures of plan and track, one row each, with what lies between them. */
function CompareTable({
rows,
plan,
planColor,
unit,
}: {
rows: readonly CompareRow[];
plan: PlanValues;
planColor: string;
unit: DistanceUnit;
}) {
const { t } = useTranslation();
return (
<div className={`${BOX} overflow-hidden`}>
<table data-testid="tour-activity-compare" className="w-full border-collapse" style={fs(12, 'body')}>
<thead className="bg-surface-secondary">
<tr>
<td />
<ColumnHead label={t('tours.activity.compare.plan')} swatch={<LineSwatch color={planColor} />} />
<ColumnHead label={t('tours.activity.badge')} swatch={<LineSwatch color={ACTUAL_TRACK_COLOR} dotted />} />
<ColumnHead label={t('tours.activity.compare.difference')} />
</tr>
</thead>
<tbody>
{rows.map((row) => {
const actual = actualCell(row, unit, t);
return (
<tr key={row.key} data-testid={`tour-activity-compare-${row.key}`} className="border-t border-edge-faint">
<th scope="row" className="px-2.5 py-1.5 text-start font-medium text-content-muted">
{t(ROW_LABELS[row.key])}
</th>
<ValueCell text={planText(row, plan, unit, t)} missing={row.plan === null} />
<ValueCell text={actual.text} hint={actual.hint} missing={row.actual === null} strong />
<DifferenceCell row={row} unit={unit} />
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/** How far the track left the planned line, or that there is no line to measure against. */
function deviationFields(
activity: TourActivity,
plan: PlanValues,
unit: DistanceUnit,
t: Translate
): TourMetricField[] {
const deviation = planDeviation(activity.geometry, plan.line);
const offLabel = t('tours.activity.compare.offPlan');
if (!deviation) return [{ label: offLabel, value: t('tours.activity.compare.noLine') }];
const offInfo = {
label: offLabel,
description: t('tours.activity.compare.offPlanHint', { distance: formatDistance(OFF_PLAN_M / 1000, unit) }),
};
return [
{ label: t('tours.activity.compare.farthest'), value: formatDistance(deviation.farthestM / 1000, unit) },
{
label: offLabel,
value: t('tours.activity.compare.offPlanValue', {
distance: formatDistance(deviation.offPlanKm, unit),
percent: Math.round(deviation.offPlanShare * 100),
}),
info: offInfo,
},
];
}
interface TourActivityCompareProps {
activity: TourActivity;
plan: PlanValues;
/** The plan's track colour, as the map draws it. */
planColor: string;
unit: DistanceUnit;
/** The switch for the plan map; left out where no plan map is beside the detail (the phone). */
map?: { shown: boolean; onToggle: () => void };
}
/**
* ACT-02 in an actual track's card: its values beside the plan's, each side
* named and drawn as it is on the map, the difference only where both are
* known, and how far the track strayed from the planned line. It reads both
* and changes neither; the map switch only draws.
*/
export default function TourActivityCompare({ activity, plan, planColor, unit, map }: TourActivityCompareProps) {
const { t } = useTranslation();
const rows = useMemo(() => compareRows(plan, activity.metrics), [activity.metrics, plan]);
const deviation = useMemo(() => deviationFields(activity, plan, unit, t), [activity, plan, unit, t]);
const mapLabel = t(map?.shown ? 'tours.activity.compare.hide' : 'tours.activity.compare.show');
return (
<section aria-label={t('tours.activity.compare.title')} className="flex flex-col gap-2">
<div className="flex min-h-[28px] items-center gap-2">
<Eyebrow>{t('tours.activity.compare.title')}</Eyebrow>
{map && activity.geometry.length >= 2 && (
<span className="ms-auto inline-flex">
<Tooltip label={t('tours.activity.compare.showHint')} disabled={map.shown}>
<span className="inline-flex">
<CardAction icon={<MapIcon size={12} strokeWidth={2.2} />} onClick={map.onToggle} pressed={map.shown}>
{mapLabel}
</CardAction>
</span>
</Tooltip>
</span>
)}
</div>
<CompareTable rows={rows} plan={plan} planColor={planColor} unit={unit} />
<TourFactFields fields={deviation} columns={deviation.length === 2 ? 2 : undefined} />
</section>
);
}
@@ -0,0 +1,119 @@
// FE-ACT-DIALOG-001 to FE-ACT-DIALOG-004
// ACT-01, the link dialog: the day, the recordings with one taken by another
// tour dimmed, what the choice measures, the GPX import as the second way in,
// and only "Link" writing, once a free recording is chosen.
import { fireEvent, render, screen } from '@testing-library/react';
import type { TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import TourActivityDialog from './TourActivityDialog';
import type { TourActivities } from './useTourActivities';
import type { TrackAssignment } from './useTrackAssignment';
const hook = vi.hoisted(() => ({ assignment: null as unknown }));
vi.mock('./useTrackAssignment', () => ({ useTrackAssignment: () => hook.assignment }));
vi.mock('../TourGpxPreviewDialog', () => ({ default: () => null }));
const tour = { place_id: 7, name: 'Ridge', tour_type: 'hike' } as TourListItem;
const recording = (placeId: number, name: string) =>
({
place_id: placeId,
name,
tour_type: 'hike',
origin: 'gpx',
has_waypoints: false,
distance: 12.4,
track_started_at: '2026-07-02T06:10:00Z',
track_ended_at: '2026-07-02T09:25:00Z',
}) as TourListItem;
function assignment(overrides: Partial<TrackAssignment> = {}): TrackAssignment {
const watch = { tour: recording(20, 'Watch export'), takenBy: null };
return {
executions: [{ assignmentId: 11, day: { id: 1, date: '2026-07-02', title: null }, index: 0 }],
assignmentId: 11,
execution: { assignmentId: 11, day: null, index: 0 },
chooseExecution: vi.fn(),
candidates: [watch, { tour: recording(21, 'Phone export'), takenBy: 'Lake loop' }],
selected: 20,
choose: vi.fn(),
choice: watch,
replaces: [],
gpxImport: {
importing: false,
preview: null,
pick: vi.fn(),
confirm: vi.fn(),
cancel: vi.fn(),
importFiles: vi.fn(),
},
importFields: { tour_type: 'hike' },
ready: true,
...overrides,
} as unknown as TrackAssignment;
}
const state = (overrides: Partial<TourActivities> = {}) =>
({
busy: false,
canEdit: true,
assigning: {},
assign: vi.fn(),
closeAssign: vi.fn(),
...overrides,
}) as unknown as TourActivities;
const show = (current: TourActivities = state()) =>
render(<TourActivityDialog tripId={1} tour={tour} executions={[]} state={current} activities={[]} />);
beforeEach(() => {
hook.assignment = assignment();
});
describe('TourActivityDialog (ACT-01)', () => {
it('FE-ACT-DIALOG-001: offers the recordings, the taken one dimmed with its tour, and what the choice measures', () => {
show();
expect(screen.getByText('tours.activity.dialogEyebrow')).toBeInTheDocument();
expect(screen.getByText('tours.activity.dialogHint')).toBeInTheDocument();
const [watch, phone] = screen.getAllByTestId('tour-activity-candidate');
expect(watch).toHaveAttribute('aria-checked', 'true');
expect(phone).toBeDisabled();
expect(phone).toHaveTextContent('tours.activity.ofTour');
expect(watch).toHaveTextContent('3 h 15 min');
expect(screen.getByText('tours.activity.time')).toBeInTheDocument();
});
it('FE-ACT-DIALOG-002: only Link writes, with the chosen recording and day', () => {
const current = state();
show(current);
fireEvent.click(screen.getAllByTestId('tour-activity-candidate')[0] as HTMLElement);
expect((hook.assignment as TrackAssignment).choose).toHaveBeenCalledWith(20);
expect(current.assign).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('tour-activity-confirm'));
expect(current.assign).toHaveBeenCalledWith(20, 11);
});
it('FE-ACT-DIALOG-003: nothing to link until a free recording is chosen; what it replaces is said', () => {
hook.assignment = assignment({ ready: false, choice: null, selected: null, replaces: ['Old export'] });
const current = state();
show(current);
expect(screen.getByTestId('tour-activity-confirm')).toBeDisabled();
fireEvent.click(screen.getByTestId('tour-activity-confirm'));
expect(current.assign).not.toHaveBeenCalled();
expect(screen.getByText('tours.activity.replaces')).toBeInTheDocument();
expect(screen.queryByText('tours.activity.time')).toBeNull();
});
it('FE-ACT-DIALOG-004: the GPX import is the way in when the trip has none; a list still loading waits', () => {
const empty = assignment({ candidates: [], choice: null, selected: null, ready: false });
hook.assignment = empty;
const { unmount } = show();
expect(screen.getByText('tours.activity.noCandidates')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /tours.activity.importGpx/ }));
expect(empty.gpxImport.pick).toHaveBeenCalled();
unmount();
hook.assignment = assignment({ candidates: null, choice: null, selected: null, ready: false });
show();
expect(screen.getByLabelText('common.loading')).toBeInTheDocument();
});
});
@@ -0,0 +1,284 @@
import type { TourActivity, TourListItem } from '@trek/shared';
import { AlertTriangle, Check, Clock, FileDown, Footprints, Link2, Loader2, Ruler, Upload } from 'lucide-react';
import { useId, useMemo } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTripStore } from '../../../store/tripStore';
import { formatDate } from '../../../utils/formatters';
import { analyzeRouteGeometry } from '../../../utils/routeGeometry';
import { formatDistance } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { PillSelect } from '../../shared/dialogParts';
import TourGpxPreviewDialog from '../TourGpxPreviewDialog';
import { CardAction, TourMetricFields, TourNotice } from '../tourParts';
import { formatPlannedTourDuration, hikeSourceBadgeLabel } from '../tourPresentation';
import { recordedDay, recordedMinutes, type TourExecution, type TrackCandidate } from './tourActivityModel';
import type { TourActivities } from './useTourActivities';
import { useTrackAssignment, type TrackAssignment } from './useTrackAssignment';
/** The value of the execution picker for "no day". */
const NO_DAY = 'none';
/** One recording of the trip to choose: its name and what it is as badges; one taken by another tour is dimmed. */
function CandidateRow({
candidate,
selected,
onChoose,
}: {
candidate: TrackCandidate;
selected: boolean;
onChoose: () => void;
}) {
const { t, locale } = useTranslation();
const unit = useSettingsStore((store) => store.settings.distance_unit) ?? 'metric';
const { tour, takenBy } = candidate;
const minutes = recordedMinutes(tour.track_started_at, tour.track_ended_at);
const recordedOn = recordedDay(tour.track_started_at, locale);
return (
<button
type="button"
role="radio"
aria-checked={selected}
disabled={takenBy !== null}
onClick={onChoose}
data-testid="tour-activity-candidate"
className={`flex w-full items-center gap-2.5 rounded-[12px] border px-3 py-2.5 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-55 ${selected ? 'border-accent bg-surface-card shadow-sm' : 'border-edge-faint bg-surface-card enabled:hover:bg-surface-hover'}`}
>
<span
aria-hidden="true"
className={`grid h-5 w-5 flex-none place-items-center rounded-full ring-1 ring-inset ${selected ? 'bg-accent text-accent-text ring-accent' : 'ring-edge'}`}
>
{selected && <Check size={12} strokeWidth={2.6} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>
{tour.name}
</span>
<span className="mt-1 flex flex-wrap items-center gap-1">
<SoftPill icon={<FileDown size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{hikeSourceBadgeLabel(tour, t)}
</SoftPill>
{tour.distance != null && (
<SoftPill icon={<Ruler size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{formatDistance(tour.distance, unit)}
</SoftPill>
)}
{recordedOn && (
<SoftPill icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{minutes === null ? recordedOn : `${recordedOn} ${formatPlannedTourDuration(minutes)}`}
</SoftPill>
)}
{takenBy !== null && <SoftPill tone="warning">{t('tours.activity.ofTour', { name: takenBy })}</SoftPill>}
</span>
</span>
</button>
);
}
/** The recordings to choose from, or what to do while there is none. */
function CandidateList({ assignment }: { assignment: TrackAssignment }) {
const { t } = useTranslation();
const { candidates } = assignment;
if (candidates === null) {
return (
<div className="flex justify-center py-3 text-content-faint">
<Loader2 size={16} className="animate-spin" aria-label={t('common.loading')} />
</div>
);
}
if (candidates.length === 0) {
return (
<TourNotice role="status" icon={<Footprints size={13} strokeWidth={2} />}>
{t('tours.activity.noCandidates')}
</TourNotice>
);
}
return (
<div role="radiogroup" aria-label={t('tours.activity.track')} className="flex flex-col gap-1.5">
{candidates.map((candidate) => (
<CandidateRow
key={candidate.tour.place_id}
candidate={candidate}
selected={candidate.tour.place_id === assignment.selected}
onChoose={() => assignment.choose(candidate.tour.place_id)}
/>
))}
</div>
);
}
/** What the chosen recording measures, read the way the detail will show it, before anything is written. */
function ChoicePreview({ choice }: { choice: TourListItem }) {
const { t } = useTranslation();
const unit = useSettingsStore((store) => store.settings.distance_unit) ?? 'metric';
const line = useTripStore((store) => store.places.find((place) => place.id === choice.place_id)?.route_geometry);
const analysis = useMemo(() => analyzeRouteGeometry(line), [line]);
const minutes = recordedMinutes(choice.track_started_at, choice.track_ended_at);
return (
<DialogSection label={t('tours.activity.badge')}>
<TourMetricFields
analysis={analysis}
unit={unit}
extra={[
{
label: t('tours.activity.time'),
value: minutes === null ? t('tours.activity.noTime') : formatPlannedTourDuration(minutes),
},
]}
/>
</DialogSection>
);
}
/** The day picker: every day the tour is on, and no day at all. */
function ExecutionPicker({ assignment }: { assignment: TrackAssignment }) {
const { t, locale } = useTranslation();
const options = [
...assignment.executions.map((execution: TourExecution) => ({
value: String(execution.assignmentId),
label: t('dayplan.dayN', { n: execution.index + 1 }),
hint: (execution.day && formatDate(execution.day.date, locale)) || undefined,
})),
{ value: NO_DAY, label: t('tours.activity.noDay') },
];
return (
<PillSelect
quiet
value={assignment.assignmentId === null ? NO_DAY : String(assignment.assignmentId)}
options={options}
onChange={(value) => assignment.chooseExecution(value === NO_DAY ? null : Number(value))}
label={t('tours.activity.execution')}
/>
);
}
interface TourActivityDialogProps {
tripId: number;
tour: TourListItem;
executions: readonly TourExecution[];
state: TourActivities;
activities: readonly TourActivity[];
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
}
/**
* ACT-01: assigning a recorded track, on purpose and confirmed. The user picks
* the day it was done on and a recording of the trip, or imports one through
* the GPX import as always, sees what it measures, and only "Assign" writes.
* The plan is not touched, and the head says so.
*/
export default function TourActivityDialog({
tripId,
tour,
executions,
state,
activities,
onToursChanged,
}: TourActivityDialogProps) {
const { t } = useTranslation();
const labelId = useId();
const assignment = useTrackAssignment({
tripId,
tour,
executions,
activities,
target: state.assigning ?? {},
canEdit: state.canEdit,
onToursChanged,
});
const { gpxImport, choice, selected } = assignment;
const importing = gpxImport.importing || gpxImport.preview !== null;
const confirm = () => {
if (selected !== null && assignment.ready) void state.assign(selected, assignment.assignmentId);
};
return (
<>
<DialogShell
onClose={state.closeAssign}
labelledBy={labelId}
width="detail"
align="top"
blocked={state.busy || importing}
header={
<DialogHeader
tile={
<DialogTile>
<Footprints size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={state.closeAssign}
eyebrow={t('tours.activity.dialogEyebrow')}
title={tour.name}
sub={t('tours.activity.dialogHint')}
subWraps
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={state.closeAssign} disabled={state.busy}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="tour-activity-confirm"
disabled={!assignment.ready || state.busy || importing}
icon={
state.busy ? <Loader2 size={14} className="animate-spin" /> : <Link2 size={14} strokeWidth={2} />
}
onClick={confirm}
>
{t('tours.activity.confirm')}
</DialogButton>
</DialogFooter>
}
>
<DialogSection label={t('tours.activity.execution')}>
<ExecutionPicker assignment={assignment} />
</DialogSection>
<DialogSection
label={t('tours.activity.track')}
action={
<CardAction
icon={
gpxImport.importing ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Upload size={12} strokeWidth={2.2} />
)
}
onClick={gpxImport.pick}
disabled={importing}
>
{t('tours.activity.importGpx')}
</CardAction>
}
>
<div className="flex flex-col gap-2">
<CandidateList assignment={assignment} />
{assignment.replaces.length > 0 && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.activity.replaces', { name: assignment.replaces.join(', ') })}
</TourNotice>
)}
</div>
</DialogSection>
{choice && choice.takenBy === null && <ChoicePreview choice={choice.tour} />}
</DialogShell>
<TourGpxPreviewDialog gpxImport={gpxImport} initial={assignment.importFields} />
</>
);
}
@@ -0,0 +1,194 @@
// FE-ACT-SECTION-001 to FE-ACT-SECTION-008
// ACT-01 in the tour's detail, rendered without the translation provider so
// every text reads as its key: the actual tracks with their own values marked
// as such beside the plan's (ACT-02), a time only where the track measured
// one, the actions only for someone who may change the tour, and the map
// comparison only where a plan map is beside the detail and can draw it.
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { TourActivity, TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import type { Day } from '../../../types';
import TourActivitySection from './TourActivitySection';
import type { PlanValues } from './tourActivityCompare';
import type { TourActivities } from './useTourActivities';
const hook = vi.hoisted(() => ({ state: null as unknown }));
vi.mock('./useTourActivities', () => ({ useTourActivities: () => hook.state }));
vi.mock('./TourActivityDialog', () => ({ default: () => <div data-testid="tour-activity-dialog" /> }));
const tour = { place_id: 7, name: 'Ridge', tour_type: 'hike' } as TourListItem;
const days = [{ id: 1, date: '2026-07-02', title: null }] as unknown as Day[];
const assignments = { '1': [{ id: 11, place_id: 7 }] } as never;
function activity(overrides: Partial<TourActivity['metrics']> = {}, assignmentId: number | null = 11): TourActivity {
return {
id: 4,
assignment_id: assignmentId,
day_id: 1,
assigned_at: null,
track: { place_id: 20, name: 'Watch export', origin: 'gpx', file_name: 'morning.gpx', imported_at: null },
metrics: {
distance_km: 0.1,
elevation_gain_m: 100,
elevation_loss_m: 0,
started_at: '2026-07-02T06:10:00Z',
ended_at: '2026-07-02T09:25:00Z',
elapsed_minutes: 195,
point_count: 2,
...overrides,
},
geometry: [
[47, 11, 600],
[47.001, 11, 700],
],
};
}
function state(overrides: Partial<TourActivities> = {}): TourActivities {
return {
activities: [activity()],
unavailable: false,
canEdit: true,
busy: false,
assigning: null,
openAssign: vi.fn(),
closeAssign: vi.fn(),
assign: vi.fn(),
removing: null,
askRemove: vi.fn(),
cancelRemove: vi.fn(),
remove: vi.fn(),
...overrides,
} as TourActivities;
}
const plan: PlanValues = { distanceKm: 0.1, gain: 80, loss: 0, minutes: 180, minutesAtLeast: false, line: null };
const show = (canEdit = true, item: TourListItem = tour, mapCompare = false) =>
render(
<TourActivitySection
tripId={1}
tour={item}
days={days}
assignments={assignments}
canEdit={canEdit}
plan={plan}
planColor="plan-paint"
mapCompare={mapCompare}
/>
);
beforeEach(() => {
hook.state = state();
useTourActivityCompareStore.setState({ comparison: null, mapAvailable: true });
});
describe('TourActivitySection (ACT-01)', () => {
it("FE-ACT-SECTION-001: shows an actual track with its day, its file, its own values beside the plan's, and its profile", () => {
show();
expect(screen.getByText('tours.activity.label')).toBeInTheDocument();
expect(screen.getByText('Watch export')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-day')).toHaveTextContent('dayplan.dayN');
expect(screen.getByText('morning.gpx')).toBeInTheDocument();
expect(screen.getAllByText('tours.activity.badge').length).toBeGreaterThan(0);
expect(screen.getByTestId('tour-activity-compare-time')).toHaveTextContent('3 h 15 min');
expect(screen.getByTestId('tour-activity-compare-ascent')).toHaveTextContent('+20 m');
});
it('FE-ACT-SECTION-002: a track without times measures no time, and a track without a day says so', () => {
hook.state = state({ activities: [activity({ elapsed_minutes: null, started_at: null, ended_at: null }, null)] });
show();
expect(screen.getByTestId('tour-activity-compare-time')).toHaveTextContent('tours.activity.noTime');
expect(screen.getByTestId('tour-activity-day')).toHaveTextContent('tours.activity.noDay');
expect(screen.queryByText('tours.activity.recordedOn')).toBeNull();
});
it('FE-ACT-SECTION-003: someone who may change the tour links, changes and unlinks; the dialog opens when asked', () => {
const current = state();
hook.state = current;
const { rerender } = show();
fireEvent.click(screen.getByRole('button', { name: /tours.activity.assign$/ }));
expect(current.openAssign).toHaveBeenCalledWith();
fireEvent.click(screen.getByRole('button', { name: /tours.activity.change/ }));
expect(current.openAssign).toHaveBeenCalledWith({ assignmentId: 11, activityId: 4 });
fireEvent.click(screen.getByRole('button', { name: /tours.activity.remove/ }));
expect(current.askRemove).toHaveBeenCalledWith(current.activities?.[0]);
hook.state = state({ assigning: {}, removing: activity() });
rerender(<TourActivitySection tripId={1} tour={tour} days={days} assignments={assignments} canEdit />);
expect(screen.getByTestId('tour-activity-dialog')).toBeInTheDocument();
expect(screen.getByText('tours.activity.removeTitle')).toBeInTheDocument();
});
it('FE-ACT-SECTION-004: without the right to change it, the values show and no action does', () => {
hook.state = state({ canEdit: false });
show(false);
expect(screen.getByText('Watch export')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /tours.activity.assign$/ })).toBeNull();
expect(screen.queryByRole('button', { name: /tours.activity.remove/ })).toBeNull();
});
it('FE-ACT-SECTION-005: an empty place for one when it may be linked; nothing while loading, when it cannot, or for a track of another tour', () => {
hook.state = state({ activities: [] });
const { unmount } = show();
expect(screen.getByTestId('tour-activity-empty')).toHaveTextContent('tours.activity.empty');
unmount();
hook.state = state({ activities: [], canEdit: false });
const readOnly = show(false);
expect(readOnly.container).toBeEmptyDOMElement();
readOnly.unmount();
hook.state = state({ activities: null });
const loading = show();
expect(loading.container).toBeEmptyDOMElement();
loading.unmount();
hook.state = state();
const track = show(true, { ...tour, activity_of: 3 });
expect(track.container).toBeEmptyDOMElement();
});
it('FE-ACT-SECTION-006: tracks that could not be read are said to be unavailable, never shown as none, and nothing is linked over them', () => {
hook.state = state({ activities: [], unavailable: true });
const { unmount } = show();
expect(screen.getByRole('status')).toHaveTextContent('tours.activity.unavailable');
expect(screen.queryByTestId('tour-activity-empty')).toBeNull();
expect(screen.getByRole('button', { name: /tours.activity.assign$/ })).toBeDisabled();
unmount();
hook.state = state({ activities: [], unavailable: true, canEdit: false });
show(false);
expect(screen.getByText('tours.activity.unavailable')).toBeInTheDocument();
});
it('FE-ACT-SECTION-007: beside a plan map a track is compared there and back, and on the phone it is not offered', () => {
const { unmount } = show(false, tour, true);
fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.show/ }));
expect(useTourActivityCompareStore.getState().comparison).toEqual({ tourPlaceId: 7, trackPlaceId: 20 });
expect(screen.getByRole('button', { name: /tours.activity.compare.hide/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.hide/ }));
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 }));
unmount();
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
show(false);
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
});
it('FE-ACT-SECTION-008: in road trip mode or with the overview open the map draws other lines, so no switch is offered and a running comparison ends', () => {
show(false, tour, true);
fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.show/ }));
expect(useTourActivityCompareStore.getState().comparison).not.toBeNull();
act(() => useTourActivityCompareStore.getState().setMapAvailable(false));
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
expect(screen.queryByRole('button', { name: /tours.activity.compare.(show|hide)/ })).toBeNull();
act(() => useTourActivityCompareStore.getState().setMapAvailable(true));
expect(screen.getByRole('button', { name: /tours.activity.compare.show/ })).toHaveAttribute('aria-pressed', 'false');
});
});
@@ -0,0 +1,296 @@
import type { TourActivity, TourListItem } from '@trek/shared';
import { CalendarDays, Clock, CloudOff, FileDown, Footprints, Plus, RefreshCw, Unlink } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import type { AssignmentsMap, Day } from '../../../types';
import { BOX } from '../../Planner/bookings/bookingParts';
import { SoftPill } from '../../Planner/planParts';
import ConfirmDialog from '../../shared/ConfirmDialog';
import { DialogSection, fs } from '../../shared/DialogShell';
import ElevationProfile from '../../shared/ElevationProfile';
import { Tooltip } from '../../shared/Tooltip';
import { dayMenuLabel } from '../tourDays';
import { CardAction, TourNotice } from '../tourParts';
import TourActivityCompare from './TourActivityCompare';
import TourActivityDialog from './TourActivityDialog';
import { ACTUAL_TRACK_COLOR } from './activityCompareParts';
import type { PlanValues } from './tourActivityCompare';
import { analysisOfLine, executionOf, executionsOf, recordedDay, type TourExecution } from './tourActivityModel';
import { useActivityComparison, type ActivityComparisonState } from './useActivityComparison';
import { useTourActivities, type TourActivities } from './useTourActivities';
/** What a card compares its track with: the plan's values and colour, and the map switch where there is a plan map. */
interface PlanSide {
plan: PlanValues;
planColor: string;
/** Null on the phone, whose detail has no plan map beside it. */
comparison: ActivityComparisonState | null;
}
/** The tile of a recording: footprints, with a small dot at its foot that says it was recorded. */
function RecordedTile() {
return (
<span className="relative grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-secondary text-content-secondary">
<Footprints size={15} strokeWidth={2} aria-hidden="true" />
<span
aria-hidden="true"
className="absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full bg-success ring-2 ring-surface-card"
/>
</span>
);
}
/** Where and when: the execution, the file it came from, the day it was recorded, each a badge of its own. */
function ActivityBadges({ activity, execution }: { activity: TourActivity; execution: TourExecution }) {
const { t, locale } = useTranslation();
const dayLabel = execution.day ? t('dayplan.dayN', { n: execution.index + 1 }) : t('tours.activity.noDay');
const dayTitle = execution.day ? dayMenuLabel(execution.day, execution.index, t, locale) : dayLabel;
const recordedOn = recordedDay(activity.metrics.started_at, locale);
const source =
activity.track.file_name ?? t(activity.track.origin === 'wanderer' ? 'tours.badge.wanderer' : 'tours.badge.gpx');
return (
<div className="mt-1 flex flex-wrap items-center gap-1">
<Tooltip label={dayTitle}>
<span className="inline-flex" data-testid="tour-activity-day">
<SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{dayLabel}
</SoftPill>
</span>
</Tooltip>
<SoftPill
className="max-w-[180px]"
icon={<FileDown size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
>
<span className="min-w-0 truncate">{source}</span>
</SoftPill>
{recordedOn && (
<SoftPill icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{t('tours.activity.recordedOn', { date: recordedOn })}
</SoftPill>
)}
</div>
);
}
/**
* One recording: who it is, its values beside the plan's (ACT-02), its
* profile, and what the user can do with it.
*/
function ActivityCard({
activity,
execution,
state,
side,
}: {
activity: TourActivity;
execution: TourExecution;
state: TourActivities;
side: PlanSide;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((store) => store.settings.distance_unit) ?? 'metric';
const analysis = useMemo(() => analysisOfLine(activity.geometry), [activity.geometry]);
const trackPlaceId = activity.track.place_id;
const { comparison } = side;
return (
<article data-testid="tour-activity" className={`${BOX} overflow-hidden`}>
<header className="flex items-start gap-2.5 px-3 py-2.5">
<RecordedTile />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>
{activity.track.name}
</div>
<ActivityBadges activity={activity} execution={execution} />
</div>
<Tooltip label={t('tours.activity.badgeHint')}>
<span className="inline-flex">
<SoftPill tone="success" caps>
{t('tours.activity.badge')}
</SoftPill>
</span>
</Tooltip>
</header>
<div className="flex flex-col gap-2 border-t border-edge-faint p-2.5">
<TourActivityCompare
activity={activity}
plan={side.plan}
planColor={side.planColor}
unit={unit}
map={
comparison
? { shown: comparison.comparedTrack === trackPlaceId, onToggle: () => comparison.toggle(trackPlaceId) }
: undefined
}
/>
{analysis && analysis.distanceIndexedProfileSamples.length >= 2 && (
<ElevationProfile
samples={analysis.distanceIndexedProfileSamples}
color={ACTUAL_TRACK_COLOR}
gradientId={`tour-activity-${activity.id}`}
/>
)}
</div>
{state.canEdit && (
<footer className="flex flex-wrap gap-1.5 border-t border-edge-faint px-2.5 py-2">
<CardAction
icon={<RefreshCw size={12} strokeWidth={2.2} />}
onClick={() => state.openAssign({ assignmentId: activity.assignment_id, activityId: activity.id })}
disabled={state.busy}
>
{t('tours.activity.change')}
</CardAction>
<CardAction
icon={<Unlink size={12} strokeWidth={2.2} />}
onClick={() => state.askRemove(activity)}
disabled={state.busy}
>
{t('tours.activity.remove')}
</CardAction>
</footer>
)}
</article>
);
}
/** No recording yet: a quiet line, dimmed rather than hidden, so the place for one is known. */
function NoActivity() {
const { t } = useTranslation();
return (
<div
data-testid="tour-activity-empty"
className={`${BOX} flex items-center gap-2.5 px-3 py-2.5 text-content-faint`}
style={fs(12, 'body')}
>
<span className="grid h-7 w-7 flex-none place-items-center rounded-full border border-dashed border-edge">
<Footprints size={13} strokeWidth={2} aria-hidden="true" />
</span>
{t('tours.activity.empty')}
</div>
);
}
/** What the section holds: the tracks, the empty place for one, or word that the tour's tracks could not be read. */
function ActivityList({
state,
executions,
side,
}: {
state: TourActivities;
executions: readonly TourExecution[];
side: PlanSide;
}) {
const { t } = useTranslation();
const activities = state.activities ?? [];
if (state.unavailable) {
return (
<TourNotice role="status" icon={<CloudOff size={13} strokeWidth={2} />}>
{t('tours.activity.unavailable')}
</TourNotice>
);
}
if (activities.length === 0) return <NoActivity />;
return (
<div className="flex flex-col gap-2">
{activities.map((activity) => (
<ActivityCard
key={activity.id}
activity={activity}
execution={executionOf(executions, activity.assignment_id)}
state={state}
side={side}
/>
))}
</div>
);
}
interface TourActivitySectionProps {
tripId: number;
tour: TourListItem;
days: Day[];
assignments: AssignmentsMap;
canEdit: boolean;
/** ACT-02: what the plan says, for each track's values to stand beside. */
plan: PlanValues;
/** The plan's track colour, as the map and the table draw it. */
planColor: string;
/** Offers to compare a track on the plan map; only where one is beside the detail and the map can draw it. */
mapCompare?: boolean;
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
}
/**
* ACT-01 in the tour's detail: the tracks the tour was actually done with,
* each with the values its own recording measures and its profile, apart from
* the planned values above; and for someone who may change the tour, the way
* to assign one, change it or take it off. Each track's values stand beside
* the plan's, and one track at a time can be compared with the plan on the map
* (ACT-02). A tour that is itself the recording of another shows none: it says
* so in its head instead.
*/
export default function TourActivitySection({
tripId,
tour,
days,
assignments,
canEdit,
plan,
planColor,
mapCompare = false,
onToursChanged,
}: TourActivitySectionProps) {
const { t } = useTranslation();
const executions = useMemo(() => executionsOf(tour.place_id, days, assignments), [assignments, days, tour.place_id]);
const state = useTourActivities({ tripId, tour, executions, canEdit, onToursChanged });
const { activities, unavailable } = state;
const comparison = useActivityComparison(tour.place_id, activities);
// The plan map says whether it can draw a comparison; where it cannot, no switch is offered.
const mapAvailable = useTourActivityCompareStore((store) => store.mapAvailable);
const side: PlanSide = { plan, planColor, comparison: mapCompare && mapAvailable ? comparison : null };
if (activities === null || tour.activity_of != null || (activities.length === 0 && !canEdit && !unavailable)) {
return null;
}
return (
<DialogSection
className="flex-none"
label={t('tours.activity.label')}
action={
canEdit && (
<Tooltip label={t('tours.activity.assignHint')}>
<span className="inline-flex">
<CardAction
icon={<Plus size={12} strokeWidth={2.2} />}
onClick={() => state.openAssign()}
disabled={state.busy || unavailable}
>
{t('tours.activity.assign')}
</CardAction>
</span>
</Tooltip>
)
}
>
<ActivityList state={state} executions={executions} side={side} />
{state.assigning && (
<TourActivityDialog
tripId={tripId}
tour={tour}
executions={executions}
state={state}
activities={activities}
onToursChanged={onToursChanged}
/>
)}
<ConfirmDialog
isOpen={state.removing !== null}
onClose={state.cancelRemove}
onConfirm={() => void state.remove()}
title={t('tours.activity.removeTitle')}
message={t('tours.activity.removeBody')}
confirmLabel={t('tours.activity.remove')}
/>
</DialogSection>
);
}
@@ -0,0 +1,28 @@
/**
* ACT-02: how plan and actual track look wherever they are named side by
* side, so the detail's table and the map's legend show the same two lines:
* the plan solid in its track colour, the actual track dotted in the colour
* of done. Dotted as well as coloured, so the two stay apart for someone who
* cannot tell the hues apart, and on a screenshot in black and white.
*/
/** The colour of what was recorded, as the actual track's card and profile already use it. */
export const ACTUAL_TRACK_COLOR = 'var(--success)';
/** A short stretch of line: solid for the plan, dotted for the actual track. */
export function LineSwatch({ color, dotted = false }: { color: string; dotted?: boolean }) {
return (
<svg width="22" height="8" viewBox="0 0 22 8" aria-hidden="true" className="flex-none overflow-visible">
<line
x1="2"
y1="4"
x2="20"
y2="4"
strokeWidth={dotted ? 3.2 : 3.5}
strokeLinecap="round"
strokeDasharray={dotted ? '0.1 5.4' : undefined}
style={{ stroke: color }}
/>
</svg>
);
}
@@ -0,0 +1,108 @@
// FE-ACT-COMPARE-MODEL-001 to FE-ACT-COMPARE-MODEL-006
// ACT-02, the pure half of comparing a plan with its actual track: the values
// side by side, a difference only where both are known and comparable, and
// how far the recorded line strayed from the planned one.
import type { TourActivityMetrics } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { compareRows, MEASURED_POINTS, OFF_PLAN_M, planDeviation, type PlanValues } from './tourActivityCompare';
const plan: PlanValues = {
distanceKm: 8,
gain: 600,
loss: 550,
minutes: 240,
minutesAtLeast: false,
line: null,
};
const metrics: TourActivityMetrics = {
distance_km: 8.6,
elevation_gain_m: 640,
elevation_loss_m: null,
started_at: '2026-07-02T06:00:00Z',
ended_at: '2026-07-02T10:30:00Z',
elapsed_minutes: 270,
point_count: 400,
};
// A planned line running north along 11° E for about 2.2 km.
const planned = [
[47, 11],
[47.01, 11],
[47.02, 11],
];
describe('compareRows (ACT-02)', () => {
it('FE-ACT-COMPARE-MODEL-001: names both values of every measure and what lies between them', () => {
const rows = compareRows(plan, metrics);
expect(rows.map((row) => row.key)).toEqual(['distance', 'ascent', 'descent', 'time']);
expect(rows[0]).toEqual({ key: 'distance', plan: 8, actual: 8.6, difference: expect.closeTo(0.6, 6) });
expect(rows[1]).toEqual({ key: 'ascent', plan: 600, actual: 640, difference: 40 });
expect(rows[3]).toEqual({ key: 'time', plan: 240, actual: 270, difference: 30 });
});
it('FE-ACT-COMPARE-MODEL-002: a value one side does not carry stays missing, and so does the difference', () => {
const rows = compareRows({ ...plan, gain: null, minutes: null }, metrics);
expect(rows[1]).toEqual({ key: 'ascent', plan: null, actual: 640, difference: null });
expect(rows[2]).toEqual({ key: 'descent', plan: 550, actual: null, difference: null });
expect(rows[3]).toEqual({ key: 'time', plan: null, actual: 270, difference: null });
});
it('FE-ACT-COMPARE-MODEL-003: a planned total that is only a floor is shown but never subtracted from', () => {
const time = compareRows({ ...plan, minutesAtLeast: true }, metrics)[3];
expect(time).toEqual({ key: 'time', plan: 240, actual: 270, difference: null });
});
});
describe('planDeviation (ACT-02)', () => {
it('FE-ACT-COMPARE-MODEL-004: a track along the plan stays on it', () => {
const deviation = planDeviation(
[
[47, 11.0001],
[47.01, 11.0001],
[47.02, 11.0001],
],
planned
);
expect(deviation?.farthestM).toBeLessThan(OFF_PLAN_M);
expect(deviation?.offPlanKm).toBe(0);
expect(deviation?.offPlanShare).toBe(0);
});
it('FE-ACT-COMPARE-MODEL-005: a detour away from the plan is measured by its farthest point and its length', () => {
// Out along the plan, then about 760 m east and back to the plan's end.
const deviation = planDeviation(
[
[47, 11],
[47.01, 11],
[47.012, 11.01],
[47.018, 11.01],
[47.02, 11],
],
planned
);
expect(deviation?.farthestM).toBeGreaterThan(740);
expect(deviation?.farthestM).toBeLessThan(780);
// Only the leg between the two eastern points has both ends off the plan: about 670 m.
expect(deviation?.offPlanKm).toBeGreaterThan(0.6);
expect(deviation?.offPlanKm).toBeLessThan(0.7);
// Of about 3.4 km recorded in all.
expect(deviation?.offPlanShare).toBeGreaterThan(0.15);
expect(deviation?.offPlanShare).toBeLessThan(0.25);
});
it('FE-ACT-COMPARE-MODEL-006: nothing without two points on either line, and a long track measured in samples to its end', () => {
expect(planDeviation([[47, 11]], planned)).toBeNull();
expect(planDeviation(planned, null)).toBeNull();
expect(planDeviation(planned, [[47, 11]])).toBeNull();
// Twice as many points as are measured along the plan, then on past its end; the last point is kept.
const count = MEASURED_POINTS * 2;
const long = Array.from({ length: count }, (_, index) => [47 + (0.02 * index) / count, 11]);
long.push([47.03, 11], [47.04, 11]);
const deviation = planDeviation(long, planned);
expect(deviation?.farthestM).toBeGreaterThan(2200);
expect(deviation?.offPlanKm).toBeGreaterThan(1.1);
expect(deviation?.offPlanKm).toBeLessThan(1.12);
});
});
@@ -0,0 +1,104 @@
import { distancesToLine, haversineMetres, type TourActivityMetrics } from '@trek/shared';
/**
* ACT-02, the pure half of comparing a tour's plan with a track it was
* actually done with: the values both carry side by side, the difference only
* where both are known and mean the same thing, and how far the recorded line
* strayed from the planned one. Nothing is estimated: a value one side does
* not carry stays missing and is said to be.
*/
type Line = readonly (readonly number[])[];
/** What the plan says, as the tour's detail shows it in its head. */
export interface PlanValues {
distanceKm: number | null;
gain: number | null;
loss: number | null;
/** The planned total, breaks included, as a recording's time includes its pauses; null without one. */
minutes: number | null;
/** The total leaves out a stay nobody entered yet (CW-02): a floor, not a value to subtract from. */
minutesAtLeast: boolean;
/** The planned line, `[lat, lng]` points; null while the tour has none. */
line: Line | null;
}
export type CompareKey = 'distance' | 'ascent' | 'descent' | 'time';
/** One measure of both: kilometres, metres or minutes, as the key says. */
export interface CompareRow {
key: CompareKey;
plan: number | null;
actual: number | null;
/** Actual less planned; null unless both are known and comparable. */
difference: number | null;
}
function row(key: CompareKey, plan: number | null, actual: number | null, comparable = true): CompareRow {
return { key, plan, actual, difference: comparable && plan !== null && actual !== null ? actual - plan : null };
}
/** The four measures both sides can carry, in the order the detail names them. */
export function compareRows(plan: PlanValues, metrics: TourActivityMetrics): CompareRow[] {
return [
row('distance', plan.distanceKm, metrics.distance_km),
row('ascent', plan.gain, metrics.elevation_gain_m),
row('descent', plan.loss, metrics.elevation_loss_m),
row('time', plan.minutes, metrics.elapsed_minutes, !plan.minutesAtLeast),
];
}
/**
* How far from the planned line a recorded point may lie and still count as
* on it: a phone's position wanders by tens of metres, and a planned line runs
* down the middle of a road the track walked beside.
*/
export const OFF_PLAN_M = 50;
/**
* At most this many recorded points are measured. A track logged every second
* holds tens of thousands, each measured against every leg of the plan; every
* n-th point keeps the measuring quick and the answer within a few metres.
*/
export const MEASURED_POINTS = 1500;
interface PlanDeviation {
/** The largest distance of a recorded point from the planned line, in metres. */
farthestM: number;
/** The recorded kilometres farther than OFF_PLAN_M from the plan. */
offPlanKm: number;
/** That share of the recorded distance, 0 to 1. */
offPlanShare: number;
}
/** Every n-th point, the last always among them, so the measured line still ends where the track did. */
function sampled(points: Line): Line {
if (points.length <= MEASURED_POINTS) return points;
const step = Math.ceil(points.length / MEASURED_POINTS);
const kept = points.filter((_, index) => index % step === 0);
return kept[kept.length - 1] === points[points.length - 1] ? kept : [...kept, points[points.length - 1]];
}
/**
* Where the recorded line left the planned one: its farthest point and the
* stretches away from it. A stretch counts as away when both its ends lie
* farther than OFF_PLAN_M. Null without two points on either line.
*/
export function planDeviation(recorded: Line, planned: Line | null): PlanDeviation | null {
if (!planned || planned.length < 2 || recorded.length < 2) return null;
const points = sampled(recorded);
const distances = distancesToLine(points, planned);
if (!distances) return null;
let total = 0;
let off = 0;
let farthestM = distances[0] ?? 0;
for (let index = 1; index < points.length; index += 1) {
const from = points[index - 1];
const to = points[index];
const step = haversineMetres(Number(from[0]), Number(from[1]), Number(to[0]), Number(to[1]));
total += step;
if ((distances[index - 1] ?? 0) > OFF_PLAN_M && (distances[index] ?? 0) > OFF_PLAN_M) off += step;
farthestM = Math.max(farthestM, distances[index] ?? 0);
}
return { farthestM, offPlanKm: off / 1000, offPlanShare: total > 0 ? off / total : 0 };
}
@@ -0,0 +1,159 @@
// FE-ACT-MODEL-001 to FE-ACT-MODEL-009
// ACT-01, the pure half of a tour's actual tracks: its executions, the
// recordings that can be linked, the GPX preset, the time and the refusals.
import type { TourActivity, TourListItem } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import type { Assignment, AssignmentsMap, Day } from '../../../types';
import { formatDate } from '../../../utils/formatters';
import {
analysisOfLine,
executionOf,
executionsOf,
importFieldsFor,
inDayOrder,
isRecording,
recordedDay,
recordedMinutes,
refusalOf,
trackCandidates,
} from './tourActivityModel';
const day = (id: number, date: string | null = null) => ({ id, date, title: null }) as unknown as Day;
const days = [day(1, '2026-07-01'), day(2, '2026-07-02'), day(3, '2026-07-03')];
const assignments: AssignmentsMap = {
'1': [{ id: 11, place_id: 7 } as Assignment, { id: 12, place_id: 8 } as Assignment],
'3': [{ id: 31, place_id: 7 } as Assignment],
};
function tour(overrides: Partial<TourListItem> = {}): TourListItem {
return {
place_id: 7,
name: 'Ridge',
tour_type: 'hike',
distance: 8,
elevation_gain: 600,
elevation_loss: 600,
duration: 180,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
planned: true,
caution: false,
has_waypoints: true,
origin: 'trek',
...overrides,
};
}
const activity = (id: number, assignmentId: number | null) =>
({ id, assignment_id: assignmentId }) as unknown as TourActivity;
describe('executions', () => {
it('FE-ACT-MODEL-001: lists the days the tour is on with their assignment, in trip order', () => {
const executions = executionsOf(7, days, assignments);
expect(executions.map((execution) => [execution.assignmentId, execution.day?.id, execution.index])).toEqual([
[11, 1, 0],
[31, 3, 2],
]);
expect(executionOf(executions, 31).index).toBe(2);
expect(executionOf(executions, null)).toEqual({ assignmentId: null, day: null, index: -1 });
expect(executionOf(executions, 99).day).toBeNull();
});
it('FE-ACT-MODEL-002: orders the actual tracks by their days, the ones without a day last', () => {
const executions = executionsOf(7, days, assignments);
const ordered = inDayOrder([activity(1, null), activity(2, 31), activity(3, 11), activity(4, null)], executions);
expect(ordered.map((item) => item.id)).toEqual([3, 2, 1, 4]);
});
});
describe('candidates', () => {
it('FE-ACT-MODEL-003: a recording is an import nobody planned over', () => {
expect(isRecording(tour({ origin: 'gpx', has_waypoints: false }))).toBe(true);
expect(isRecording(tour({ origin: 'wanderer', has_waypoints: false }))).toBe(true);
expect(isRecording(tour({ origin: 'gpx', has_waypoints: true }))).toBe(false);
expect(isRecording(tour({ origin: 'trek', has_waypoints: false }))).toBe(false);
expect(isRecording(tour({ origin: undefined, has_waypoints: false }))).toBe(false);
});
it('FE-ACT-MODEL-004: offers the recordings, dims one taken by another tour and leaves out the rest', () => {
const tours = [
tour(),
tour({ place_id: 20, name: 'Watch', origin: 'gpx', has_waypoints: false }),
tour({ place_id: 21, name: 'Phone', origin: 'gpx', has_waypoints: false, activity_of: 30 }),
tour({ place_id: 22, name: 'Mine', origin: 'gpx', has_waypoints: false, activity_of: 7 }),
tour({
place_id: 23,
name: 'Plan',
origin: 'gpx',
has_waypoints: false,
activity_tracks: [{ id: 1, track_place_id: 24, assignment_id: null }],
}),
tour({ place_id: 30, name: 'Lake' }),
];
expect(trackCandidates(tours, 7).map(({ tour: item, takenBy }) => [item.place_id, takenBy])).toEqual([
[20, null],
[21, 'Lake'],
[22, null],
]);
});
});
describe('the GPX preset', () => {
it('FE-ACT-MODEL-005: imports a recording as the tour’s own type', () => {
expect(importFieldsFor(tour({ tour_type: 'bike', bike_profile: 'gravel' }))).toEqual({
tour_type: 'bike',
bike_profile: 'gravel',
});
expect(importFieldsFor(tour({ tour_type: 'bike', bike_profile: null }))).toEqual({
tour_type: 'bike',
bike_profile: 'touring',
});
expect(importFieldsFor(tour({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' }))).toEqual({
tour_type: 'kayak',
boat_type: 'sup',
water_context: 'lake',
});
expect(importFieldsFor(tour({ tour_type: 'kayak', boat_type: null, water_context: null }))).toEqual({
tour_type: 'kayak',
});
expect(importFieldsFor(tour({ tour_type: 'walk' }))).toEqual({ tour_type: 'walk' });
expect(importFieldsFor(tour({ tour_type: 'ski' }))).toEqual({ tour_type: 'hike' });
});
});
describe('measures', () => {
it('FE-ACT-MODEL-006: the time is only what the track’s times say', () => {
expect(recordedMinutes('2026-07-02T06:10:00Z', '2026-07-02T09:25:00Z')).toBe(195);
expect(recordedMinutes(null, '2026-07-02T09:25:00Z')).toBeNull();
expect(recordedMinutes('2026-07-02T09:25:00Z', '2026-07-02T06:10:00Z')).toBeNull();
expect(recordedMinutes('nonsense', undefined)).toBeNull();
});
it('FE-ACT-MODEL-007: analyses a line like the plan’s, and nothing of fewer than two points', () => {
const analysis = analysisOfLine([
[47, 11, 600],
[47.01, 11, 700],
]);
expect(analysis?.gain).toBe(100);
expect(analysisOfLine([[47, 11]])).toBeNull();
});
it('FE-ACT-MODEL-008: reads the server’s reason for a refusal, and none from anything else', () => {
expect(refusalOf({ response: { data: { code: 'activity_track_in_use' } } })).toBe('activity_track_in_use');
expect(refusalOf({ response: { data: { code: 'tour_revision_conflict' } } })).toBeNull();
expect(refusalOf(new Error('offline'))).toBeNull();
expect(refusalOf(null)).toBeNull();
});
it('FE-ACT-MODEL-009: the day recorded is the viewer’s calendar day of the first time, not the UTC one', () => {
// 22:30 UTC on the 1st is the morning of the 2nd in Tokyo and still the afternoon of the 1st in Los Angeles.
expect(recordedDay('2026-07-01T22:30:00Z', 'en', 'Asia/Tokyo')).toBe(formatDate('2026-07-02', 'en'));
expect(recordedDay('2026-07-01T22:30:00Z', 'en', 'America/Los_Angeles')).toBe(formatDate('2026-07-01', 'en'));
// 01:30 UTC on the 2nd is still the evening of the 1st in New York.
expect(recordedDay('2026-07-02T01:30:00Z', 'en', 'America/New_York')).toBe(formatDate('2026-07-01', 'en'));
expect(recordedDay(null, 'en')).toBeNull();
expect(recordedDay('nonsense', 'en')).toBeNull();
});
});
@@ -0,0 +1,142 @@
import {
TOUR_ACTIVITY_REFUSALS,
type TourActivity,
type TourActivityRefusal,
type TourGpxImportFields,
type TourListItem,
} from '@trek/shared';
import type { AssignmentsMap, Day } from '../../../types';
import { formatDate } from '../../../utils/formatters';
import { analyzeRouteGeometry, type RouteGeometryAnalysis } from '../../../utils/routeGeometry';
/**
* ACT-01, the pure half of a tour's recorded tracks: the executions a
* recording can belong to (the days the tour is on, or none), the recordings
* of the trip that can be assigned, and what one of them measures.
*/
/** One execution of a tour: a day it is on, through its day assignment; `null` assignment for none. */
export interface TourExecution {
assignmentId: number | null;
day: Day | null;
/** The day's position in the trip, so `index + 1` is the number the plan shows; -1 without a day. */
index: number;
}
/** Every day the tour is on, in the order of the trip, each with its assignment. */
export function executionsOf(placeId: number, days: readonly Day[], assignments: AssignmentsMap): TourExecution[] {
return days.flatMap((day, index) => {
const assignment = (assignments[String(day.id)] ?? []).find((item) => item.place_id === placeId);
return assignment ? [{ assignmentId: assignment.id, day, index }] : [];
});
}
/** The execution an assignment stands for; one whose day is gone reads as none. */
export function executionOf(executions: readonly TourExecution[], assignmentId: number | null): TourExecution {
return (
executions.find((execution) => execution.assignmentId === assignmentId) ?? {
assignmentId: null,
day: null,
index: -1,
}
);
}
/** The recordings in the order of the days they belong to, the ones without a day last. */
export function inDayOrder(activities: readonly TourActivity[], executions: readonly TourExecution[]): TourActivity[] {
const position = (activity: TourActivity) => {
const index = executionOf(executions, activity.assignment_id).index;
return index < 0 ? Number.MAX_SAFE_INTEGER : index;
};
return [...activities].sort((a, b) => position(a) - position(b) || a.id - b.id);
}
/** A tour of the trip that still is the line it was recorded with: an import nobody planned over. */
export function isRecording(tour: Pick<TourListItem, 'origin' | 'has_waypoints'>): boolean {
return tour.origin !== undefined && tour.origin !== 'trek' && !tour.has_waypoints;
}
/**
* What can be offered for this tour: every recording of the trip but the tour
* itself and the ones with recordings of their own. One that documents another
* tour stays in the list, dimmed, with that tour's name, so the user sees why
* it cannot be picked rather than missing it.
*/
export interface TrackCandidate {
tour: TourListItem;
/** The other tour this recording documents already; null while it is free or this tour's own. */
takenBy: string | null;
}
export function trackCandidates(tours: readonly TourListItem[], placeId: number): TrackCandidate[] {
const names = new Map(tours.map((tour) => [tour.place_id, tour.name]));
return tours
.filter((tour) => tour.place_id !== placeId && isRecording(tour) && !tour.activity_tracks?.length)
.map((tour) => {
const owner = tour.activity_of ?? null;
return { tour, takenBy: owner !== null && owner !== placeId ? (names.get(owner) ?? '') : null };
});
}
/**
* The type a recording of this tour is imported as, preset in the GPX
* preview: the tour's own, with its bike or run profile or its boat and water where
* it has them. A type the import does not offer starts as a hike, as the
* preview always did.
*/
export function importFieldsFor(
tour: Pick<TourListItem, 'tour_type' | 'bike_profile' | 'boat_type' | 'water_context'> &
Partial<Pick<TourListItem, 'run_profile'>>
): TourGpxImportFields {
switch (tour.tour_type) {
case 'bike':
return { tour_type: 'bike', bike_profile: tour.bike_profile ?? 'touring' };
case 'run':
return { tour_type: 'run', run_profile: tour.run_profile ?? 'running' };
case 'horse':
return { tour_type: 'horse' };
case 'kayak':
return {
tour_type: 'kayak',
...(tour.boat_type ? { boat_type: tour.boat_type } : {}),
...(tour.water_context ? { water_context: tour.water_context } : {}),
};
case 'walk':
return { tour_type: 'walk' };
default:
return { tour_type: 'hike' };
}
}
/** A recording's line analysed the way the plan's is, so both read alike. */
export function analysisOfLine(geometry: TourActivity['geometry']): RouteGeometryAnalysis | null {
return geometry.length >= 2 ? analyzeRouteGeometry(JSON.stringify(geometry)) : null;
}
/** Minutes from a track's first to its last time, pauses included; null unless both are there and in order. */
export function recordedMinutes(startedAt?: string | null, endedAt?: string | null): number | null {
const start = startedAt ? Date.parse(startedAt) : Number.NaN;
const end = endedAt ? Date.parse(endedAt) : Number.NaN;
return Number.isFinite(start) && Number.isFinite(end) && end > start ? Math.round((end - start) / 60_000) : null;
}
/**
* The day a track was recorded on, as the viewer's calendar reads its first
* time stamp: a hike started at 07:00 in Tokyo is that morning, not the UTC
* evening before. `timeZone` is only there for tests; null without a time.
*/
export function recordedDay(startedAt: string | null | undefined, locale: string, timeZone?: string): string | null {
const instant = startedAt ? new Date(startedAt) : null;
if (!instant || Number.isNaN(instant.getTime())) return null;
// en-CA writes the calendar day as YYYY-MM-DD, the form formatDate reads.
const day = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(
instant
);
return formatDate(day, locale);
}
/** Why the server refused an assignment, when it named a reason this build knows; null for anything else. */
export function refusalOf(error: unknown): TourActivityRefusal | null {
const code = (error as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code;
return TOUR_ACTIVITY_REFUSALS.find((known) => known === code) ?? null;
}
@@ -0,0 +1,133 @@
// FE-ACT-COMPARE-MAP-001 to FE-ACT-COMPARE-MAP-006
// ACT-02 on the plan map: while a tour is compared with its actual track, the
// track leaves the map as a tour of its own and comes back as one dotted line
// of the route, the plan stays on the map, and the legend names both; off,
// the map gets exactly what it would have had. The map also says whether it
// can compare at all, and ends a comparison where it no longer can.
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { buildPlace } from '../../../../tests/helpers/factories';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import { compareMapLines, useActivityCompareMap } from './useActivityCompareMap';
const plan = buildPlace({ id: 7, name: 'Ridge', route_color: 'plan-paint' });
const track = buildPlace({
id: 20,
name: 'Watch export',
route_geometry: JSON.stringify([
[47, 11, 600],
[47.01, 11, 640],
]),
});
const cafe = buildPlace({ id: 3, name: 'Cafe' });
const places = [plan, track, cafe];
const dayRoute: [number, number][][] = [
[
[46, 10],
[46.1, 10],
],
[
[46.1, 10],
[46.2, 10],
],
];
const paint = { line: 'actual-paint', casing: 'casing-paint' };
const input = {
enabled: true,
places,
mapPlaces: [plan, track, cafe],
route: dayRoute,
routeWalking: [true],
selectedPlaceId: 7,
};
beforeEach(() => {
useTourActivityCompareStore.setState({ comparison: null, mapAvailable: false });
});
describe('compareMapLines (ACT-02)', () => {
it('FE-ACT-COMPARE-MAP-001: lays the track into the route, dotted and in its own colour, and names both lines', () => {
const drawn = compareMapLines(input, { tourPlaceId: 7, trackPlaceId: 20 }, paint);
expect(drawn.places.map((place) => place.id)).toEqual([7, 3]);
expect(drawn.route).toEqual([
...dayRoute,
[
[47, 11],
[47.01, 11],
],
]);
// The route's own flags run short of its lines; they are read by index.
expect(drawn.routeWalking).toEqual([true, false, true]);
expect(drawn.routeColors).toEqual([undefined, undefined, paint]);
expect(drawn.legend).toEqual({ planName: 'Ridge', planColor: 'plan-paint', trackName: 'Watch export' });
});
it('FE-ACT-COMPARE-MAP-002: a plan hidden from the map comes back, and a day without a route has the track alone', () => {
const drawn = compareMapLines(
{ ...input, mapPlaces: [cafe], route: null, routeWalking: null },
{ tourPlaceId: 7, trackPlaceId: 20 },
paint
);
expect(drawn.places.map((place) => place.id)).toEqual([3, 7]);
expect(drawn.route).toHaveLength(1);
expect(drawn.routeWalking).toEqual([true]);
expect(drawn.routeColors).toEqual([paint]);
});
it('FE-ACT-COMPARE-MAP-003: off, beside another tour, elsewhere on the map or without a line, the map gets what it had', () => {
const cases = [
compareMapLines(input, null, paint),
compareMapLines({ ...input, selectedPlaceId: 3 }, { tourPlaceId: 7, trackPlaceId: 20 }, paint),
compareMapLines({ ...input, enabled: false }, { tourPlaceId: 7, trackPlaceId: 20 }, paint),
compareMapLines(input, { tourPlaceId: 7, trackPlaceId: 3 }, paint),
compareMapLines(input, { tourPlaceId: 7, trackPlaceId: 99 }, paint),
];
for (const drawn of cases) {
expect(drawn.places).toBe(input.mapPlaces);
expect(drawn.route).toBe(dayRoute);
expect(drawn.routeWalking).toBe(input.routeWalking);
expect(drawn.routeColors).toBeUndefined();
expect(drawn.legend).toBeNull();
}
});
});
describe('useActivityCompareMap (ACT-02)', () => {
it('FE-ACT-COMPARE-MAP-004: follows the comparison the detail switches on and off', () => {
const { result } = renderHook(() => useActivityCompareMap(input));
expect(result.current.legend).toBeNull();
act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 }));
expect(result.current.legend?.trackName).toBe('Watch export');
expect(result.current.route).toHaveLength(3);
act(() => useTourActivityCompareStore.getState().clear());
expect(result.current.route).toBe(dayRoute);
});
it('FE-ACT-COMPARE-MAP-005: draws the track in the colour the theme gives done', () => {
document.documentElement.style.setProperty('--success', 'success-paint');
try {
useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 });
const { result } = renderHook(() => useActivityCompareMap(input));
expect(result.current.routeColors?.[2]?.line).toBe('success-paint');
} finally {
document.documentElement.style.removeProperty('--success');
}
});
it('FE-ACT-COMPARE-MAP-006: tells the store whether it can compare, and ends a comparison where it no longer can or is gone', () => {
const { rerender, unmount } = renderHook((props: typeof input) => useActivityCompareMap(props), { initialProps: input });
expect(useTourActivityCompareStore.getState().mapAvailable).toBe(true);
act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 }));
rerender({ ...input, enabled: false });
expect(useTourActivityCompareStore.getState()).toMatchObject({ mapAvailable: false, comparison: null });
rerender(input);
expect(useTourActivityCompareStore.getState().mapAvailable).toBe(true);
act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 }));
unmount();
expect(useTourActivityCompareStore.getState()).toMatchObject({ mapAvailable: false, comparison: null });
});
});
@@ -0,0 +1,103 @@
import { useEffect, useMemo } from 'react';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import type { Place } from '../../../types';
import { parseRenderableRouteGeometry } from '../../../utils/routeGeometry';
import { useMapPaint } from '../../Map/mapPaint';
import { resolveTrackColor } from '../../Map/trackColors';
type Line = [number, number][];
type LineColor = { line: string; casing: string } | undefined;
/** The actual track's colour resolved for map paint; the map cannot read a CSS variable. */
const ACTUAL_PAINT = { line: '--success', casing: '--surface-card' } as const;
/** What the legend over the map names: the two lines, each as it is drawn. */
export interface ActivityCompareLegend {
planName: string;
planColor: string;
trackName: string;
}
interface CompareMapInput<P extends Place> {
/** False where the map shows something else (the road trip, the trip overview): nothing is compared there. */
enabled: boolean;
/** Every place of the trip, to find the plan and the recording in even when the map leaves them out. */
places: Place[];
/** The places the map draws. */
mapPlaces: P[];
route: Line[] | null;
routeWalking: boolean[] | null;
/** The place whose detail is open: the comparison shows only beside its plan. */
selectedPlaceId: number | null;
}
/** The map's input as it is drawn: unchanged, or with the comparison laid in. */
interface CompareMapLines<P extends Place> {
places: (P | Place)[];
route: Line[] | null;
routeWalking: boolean[] | null;
/** One entry per line of `route`; left out, the route keeps its own colour throughout. */
routeColors: LineColor[] | undefined;
legend: ActivityCompareLegend | null;
}
/**
* The plan map's places and lines while the comparison is on, and the
* legend's names. Pure, for the hook below and its tests.
*
* The plan is the tour's own track, picked on the map and drawn in its track
* colour; it is put back on the map should the person have hidden it there.
* The recording leaves the map as a tour of its own and comes back as one more
* line of the route, dotted in the colour of done, so it is drawn once and
* never in the colour of a plan. Off, everything stays exactly as it came.
*/
export function compareMapLines<P extends Place>(
input: CompareMapInput<P>,
comparison: { tourPlaceId: number; trackPlaceId: number } | null,
paint: { line: string; casing: string }
): CompareMapLines<P> {
const unchanged: CompareMapLines<P> = {
places: input.mapPlaces,
route: input.route,
routeWalking: input.routeWalking,
routeColors: undefined,
legend: null,
};
if (!input.enabled || !comparison || comparison.tourPlaceId !== input.selectedPlaceId) return unchanged;
const plan = input.places.find((place) => place.id === comparison.tourPlaceId);
const track = input.places.find((place) => place.id === comparison.trackPlaceId);
const line = parseRenderableRouteGeometry(track?.route_geometry);
if (!plan || !track || !line || line.length < 2) return unchanged;
const route = input.route ?? [];
const drawn = input.mapPlaces.filter((place) => place.id !== track.id);
return {
places: drawn.some((place) => place.id === plan.id) ? drawn : [...drawn, plan],
route: [...route, line.map(([lat, lng]): [number, number] => [lat, lng])],
// Aligned by index: the route's own flags may run short of its lines.
routeWalking: [...route.map((_, index) => input.routeWalking?.[index] === true), true],
routeColors: [...route.map((): LineColor => undefined), paint],
legend: { planName: plan.name, planColor: resolveTrackColor(plan), trackName: track.name },
};
}
/**
* ACT-02 on the plan map: `compareMapLines` with the comparison of the store
* and the theme's colours. The map also tells the store whether it can
* compare at all, so the detail offers the switch only where pressing it
* draws something; turning to the road trip or the overview, or leaving the
* map, ends a running comparison.
*/
export function useActivityCompareMap<P extends Place>(input: CompareMapInput<P>) {
const comparison = useTourActivityCompareStore((state) => state.comparison);
const paint = useMapPaint(ACTUAL_PAINT);
const { enabled, places, mapPlaces, route, routeWalking, selectedPlaceId } = input;
useEffect(() => {
const { setMapAvailable } = useTourActivityCompareStore.getState();
setMapAvailable(enabled);
return () => setMapAvailable(false);
}, [enabled]);
return useMemo(
() => compareMapLines({ enabled, places, mapPlaces, route, routeWalking, selectedPlaceId }, comparison, paint),
[comparison, enabled, mapPlaces, paint, places, route, routeWalking, selectedPlaceId]
);
}
@@ -0,0 +1,61 @@
// FE-ACT-COMPARE-HOOK-001 to FE-ACT-COMPARE-HOOK-004
// ACT-02 from the tour's detail: one actual track at a time is compared on
// the map, and the comparison ends with the detail or with the track's link.
import { act, renderHook } from '@testing-library/react';
import type { TourActivity } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import { useActivityComparison } from './useActivityComparison';
const linked = (trackPlaceId: number) => ({ id: trackPlaceId, track: { place_id: trackPlaceId } }) as TourActivity;
const activities = [linked(20), linked(21)];
beforeEach(() => {
useTourActivityCompareStore.getState().clear();
});
describe('useActivityComparison (ACT-02)', () => {
it('FE-ACT-COMPARE-HOOK-001: switches one track on, another instead, and off again', () => {
const { result } = renderHook(() => useActivityComparison(7, activities));
expect(result.current.comparedTrack).toBeNull();
act(() => result.current.toggle(20));
expect(result.current.comparedTrack).toBe(20);
expect(useTourActivityCompareStore.getState().comparison).toEqual({ tourPlaceId: 7, trackPlaceId: 20 });
act(() => result.current.toggle(21));
expect(result.current.comparedTrack).toBe(21);
act(() => result.current.toggle(21));
expect(result.current.comparedTrack).toBeNull();
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
});
it("FE-ACT-COMPARE-HOOK-002: another tour's comparison is not this one's, and is left alone when this detail closes", () => {
useTourActivityCompareStore.getState().show({ tourPlaceId: 9, trackPlaceId: 20 });
const { result, unmount } = renderHook(() => useActivityComparison(7, activities));
expect(result.current.comparedTrack).toBeNull();
unmount();
expect(useTourActivityCompareStore.getState().comparison).toEqual({ tourPlaceId: 9, trackPlaceId: 20 });
});
it('FE-ACT-COMPARE-HOOK-003: closing the detail ends its comparison', () => {
const { result, unmount } = renderHook(() => useActivityComparison(7, activities));
act(() => result.current.toggle(20));
unmount();
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
});
it('FE-ACT-COMPARE-HOOK-004: a track no longer linked stops being compared; one still loading is kept', () => {
const { result, rerender } = renderHook(({ list }) => useActivityComparison(7, list), {
initialProps: { list: activities as TourActivity[] | null },
});
act(() => result.current.toggle(20));
rerender({ list: null });
expect(result.current.comparedTrack).toBe(20);
rerender({ list: [linked(21)] });
expect(result.current.comparedTrack).toBeNull();
});
});
@@ -0,0 +1,43 @@
import type { TourActivity } from '@trek/shared';
import { useCallback, useEffect } from 'react';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
/**
* ACT-02 from the tour's detail: which of its actual tracks the plan map
* compares with the plan, and the switch for it. One at a time: comparing
* another ends the first. The comparison ends with the detail, and when the
* track it shows is no longer linked to this tour, so the map never compares
* a plan with a track that is not its own.
*/
export function useActivityComparison(tourPlaceId: number, activities: readonly TourActivity[] | null) {
const comparedTrack = useTourActivityCompareStore((state) =>
state.comparison?.tourPlaceId === tourPlaceId ? state.comparison.trackPlaceId : null
);
useEffect(() => {
if (comparedTrack === null || activities === null) return;
if (!activities.some((activity) => activity.track.place_id === comparedTrack)) {
useTourActivityCompareStore.getState().clear();
}
}, [activities, comparedTrack]);
useEffect(
() => () => {
const { comparison, clear } = useTourActivityCompareStore.getState();
if (comparison?.tourPlaceId === tourPlaceId) clear();
},
[tourPlaceId]
);
const toggle = useCallback(
(trackPlaceId: number) => {
const { show } = useTourActivityCompareStore.getState();
show(comparedTrack === trackPlaceId ? null : { tourPlaceId, trackPlaceId });
},
[comparedTrack, tourPlaceId]
);
return { comparedTrack, toggle };
}
export type ActivityComparisonState = ReturnType<typeof useActivityComparison>;
@@ -0,0 +1,147 @@
// FE-ACT-HOOK-001 to FE-ACT-HOOK-006
// ACT-01: the actual tracks read with the tour's detail, linked from the
// dialog and unlinked after confirming, each write telling the lists and the
// user, and a refusal named in the user's words without changing anything.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourActivity, TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TourExecution } from './tourActivityModel';
import { useTourActivities } from './useTourActivities';
const repo = vi.hoisted(() => ({ detail: vi.fn(), assignActivity: vi.fn(), unassignActivity: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const tour = { place_id: 7, name: 'Ridge', activity_tracks: [] } as unknown as TourListItem;
const executions: TourExecution[] = [{ assignmentId: 11, day: null, index: 0 }];
function activity(id: number, assignmentId: number | null, trackPlaceId = 20): TourActivity {
return {
id,
assignment_id: assignmentId,
day_id: null,
assigned_at: null,
track: { place_id: trackPlaceId, name: 'Watch', origin: 'gpx', file_name: null, imported_at: null },
metrics: {
distance_km: 3,
elevation_gain_m: null,
elevation_loss_m: null,
started_at: null,
ended_at: null,
elapsed_minutes: null,
point_count: 2,
},
geometry: [],
};
}
beforeEach(() => {
vi.clearAllMocks();
repo.detail.mockResolvedValue({ tour, waypoints: [], activities: [activity(1, null), activity(2, 11, 21)] });
});
function render(canEdit = true, onToursChanged = vi.fn()) {
return renderHook(
({ current }) => useTourActivities({ tripId: 1, tour: current, executions, canEdit, onToursChanged }),
{ initialProps: { current: tour } }
);
}
describe('useTourActivities', () => {
it('FE-ACT-HOOK-001: reads the actual tracks with the detail, in day order, and again when the list says they changed', async () => {
const { result, rerender } = render();
expect(result.current.activities).toBeNull();
await waitFor(() => expect(result.current.activities?.map((item) => item.id)).toEqual([2, 1]));
expect(repo.detail).toHaveBeenCalledWith(1, 7, expect.any(AbortSignal));
rerender({ current: { ...tour, activity_tracks: [{ id: 3, track_place_id: 22, assignment_id: null }] } });
await waitFor(() => expect(repo.detail).toHaveBeenCalledTimes(2));
});
it('FE-ACT-HOOK-002: a detail that cannot be had shows none while the list names none either', async () => {
repo.detail.mockRejectedValueOnce(new Error('offline'));
const { result } = render();
await waitFor(() => expect(result.current.activities).toEqual([]));
expect(result.current.unavailable).toBe(false);
});
it('FE-ACT-HOOK-006: a detail that cannot be had while the list names tracks says they are unavailable, not that there are none', async () => {
repo.detail.mockRejectedValueOnce(new Error('offline'));
const linked = { ...tour, activity_tracks: [{ id: 3, track_place_id: 22, assignment_id: null }] } as TourListItem;
const { result } = renderHook(() =>
useTourActivities({ tripId: 1, tour: linked, executions, canEdit: true, onToursChanged: vi.fn() })
);
await waitFor(() => expect(result.current.activities).toEqual([]));
expect(result.current.unavailable).toBe(true);
// Nothing can be replaced that could not be read.
act(() => result.current.openAssign());
expect(result.current.assigning).toBeNull();
});
it('FE-ACT-HOOK-003: links the confirmed track, takes the answer in, tells the lists and closes the dialog', async () => {
const onToursChanged = vi.fn();
repo.assignActivity.mockResolvedValue({ tour, waypoints: [], activities: [activity(5, 11, 30)] });
const { result } = render(true, onToursChanged);
await waitFor(() => expect(result.current.activities).not.toBeNull());
act(() => result.current.openAssign({ assignmentId: 11 }));
expect(result.current.assigning).toEqual({ assignmentId: 11 });
await act(async () => result.current.assign(30, 11));
expect(repo.assignActivity).toHaveBeenCalledWith(1, 7, { track_place_id: 30, assignment_id: 11 });
expect(result.current.activities?.map((item) => item.id)).toEqual([5]);
expect(onToursChanged).toHaveBeenCalledWith([7, 30]);
expect(toast.success).toHaveBeenCalledWith('tours.activity.assigned');
expect(result.current.assigning).toBeNull();
});
it('FE-ACT-HOOK-004: a refusal names its reason, keeps the dialog open and changes nothing', async () => {
repo.assignActivity.mockRejectedValue({ response: { status: 409, data: { code: 'activity_track_in_use' } } });
const { result } = render();
await waitFor(() => expect(result.current.activities).not.toBeNull());
act(() => result.current.openAssign());
await act(async () => result.current.assign(30, null));
expect(toast.error).toHaveBeenCalledWith('tours.activity.refused.activity_track_in_use');
expect(result.current.assigning).toEqual({});
expect(result.current.activities?.map((item) => item.id)).toEqual([2, 1]);
repo.assignActivity.mockRejectedValue(new Error('offline'));
await act(async () => result.current.assign(30, null));
expect(toast.error).toHaveBeenLastCalledWith('tours.activity.failed');
act(() => result.current.closeAssign());
expect(result.current.assigning).toBeNull();
});
it('FE-ACT-HOOK-005: unlinks only after the question, and nothing for someone who may not change the tour', async () => {
const onToursChanged = vi.fn();
repo.unassignActivity.mockResolvedValue({ tour, waypoints: [], activities: [] });
const { result } = render(true, onToursChanged);
await waitFor(() => expect(result.current.activities).not.toBeNull());
const target = result.current.activities?.[0] as TourActivity;
act(() => result.current.askRemove(target));
expect(result.current.removing).toBe(target);
act(() => result.current.cancelRemove());
await act(async () => result.current.remove());
expect(repo.unassignActivity).not.toHaveBeenCalled();
act(() => result.current.askRemove(target));
await act(async () => result.current.remove());
expect(repo.unassignActivity).toHaveBeenCalledWith(1, 7, 2);
expect(onToursChanged).toHaveBeenCalledWith([7, 21]);
expect(toast.success).toHaveBeenCalledWith('tours.activity.removed');
expect(result.current.activities).toEqual([]);
const readOnly = render(false);
await waitFor(() => expect(readOnly.result.current.activities).not.toBeNull());
act(() => {
readOnly.result.current.openAssign();
readOnly.result.current.askRemove(target);
});
expect(readOnly.result.current.assigning).toBeNull();
expect(readOnly.result.current.removing).toBeNull();
await act(async () => readOnly.result.current.assign(30, null));
expect(repo.assignActivity).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,145 @@
import type { TourActivity, TourDetailResponse, TourListItem } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { tourRepo } from '../../../repo/tourRepo';
import { useToast } from '../../shared/Toast';
import { inDayOrder, refusalOf, type TourExecution } from './tourActivityModel';
interface TourActivitiesOptions {
tripId: number;
tour: TourListItem;
/** The days the tour is on, each an execution a recording can belong to. */
executions: readonly TourExecution[];
canEdit: boolean;
/** Lets the trip's lists take in a change: the tour lists its recordings, a recording the tour it documents. */
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
}
/**
* Which execution the assignment dialog is open for: `undefined` lets it pick
* the first free one. `activityId` is the track a change starts from, as "no
* day" can hold several once the days they were on are gone.
*/
export interface AssignTarget {
assignmentId?: number | null;
activityId?: number;
}
/** The refs a list item names, so the detail is read again when another client changed them. */
function refsKey(tour: TourListItem): string {
return (tour.activity_tracks ?? []).map((ref) => `${ref.id}:${ref.track_place_id}:${ref.assignment_id}`).join(',');
}
/**
* ACT-01: the recorded tracks of a tour in its detail, read with the detail
* (online from the server, offline from the copy the last detail left), and
* the two changes the user can make: open the assignment for an execution,
* and take a recording off after confirming it. Both writes are online only,
* as the GPX import a recording comes through is; a refused one says why and
* changes nothing. A detail that cannot be read is not taken for an empty one:
* while the list names tracks of the tour, they are said to be unavailable.
*/
export function useTourActivities({ tripId, tour, executions, canEdit, onToursChanged }: TourActivitiesOptions) {
const { t } = useTranslation();
const toast = useToast();
const placeId = tour.place_id;
const refs = refsKey(tour);
// `activities: null` is a detail that could not be read, apart from one without tracks.
const [loaded, setLoaded] = useState<{ placeId: number; activities: TourActivity[] | null } | null>(null);
const [assigning, setAssigning] = useState<AssignTarget | null>(null);
const [removing, setRemoving] = useState<TourActivity | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
const controller = new AbortController();
tourRepo.detail(tripId, placeId, controller.signal).then(
(detail) => {
if (!controller.signal.aborted) setLoaded({ placeId, activities: detail.activities ?? [] });
},
() => {
if (!controller.signal.aborted) setLoaded({ placeId, activities: null });
}
);
return () => controller.abort();
// `refs` is the list's word that the recordings changed elsewhere.
}, [tripId, placeId, refs]);
const current = loaded?.placeId === placeId ? loaded : null;
const activities = useMemo(
() => (current ? inDayOrder(current.activities ?? [], executions) : null),
[current, executions]
);
// The list knows the tour has tracks, the detail that would show them did not come.
const unavailable = current !== null && current.activities === null && (tour.activity_tracks?.length ?? 0) > 0;
/** One write, its answer taken in, the lists told and the user too; false when it was refused. */
const apply = useCallback(
async (write: () => Promise<TourDetailResponse>, success: string, changed: number[]): Promise<boolean> => {
setBusy(true);
try {
const detail = await write();
setLoaded({ placeId, activities: detail.activities ?? [] });
await onToursChanged?.(changed);
toast.success(t(success));
return true;
} catch (error: unknown) {
const refusal = refusalOf(error);
toast.error(t(refusal ? `tours.activity.refused.${refusal}` : 'tours.activity.failed'));
return false;
} finally {
setBusy(false);
}
},
[onToursChanged, placeId, t, toast]
);
/** The confirmed assignment from the dialog; the dialog closes once it is written. */
const assign = useCallback(
async (trackPlaceId: number, assignmentId: number | null) => {
if (!canEdit || busy) return;
const done = await apply(
() => tourRepo.assignActivity(tripId, placeId, { track_place_id: trackPlaceId, assignment_id: assignmentId }),
'tours.activity.assigned',
[placeId, trackPlaceId]
);
if (done) setAssigning(null);
},
[apply, busy, canEdit, placeId, tripId]
);
/** The recording asked about, taken off once the user confirmed it. */
const remove = useCallback(async () => {
const target = removing;
if (!canEdit || !target || busy) return;
setRemoving(null);
await apply(() => tourRepo.unassignActivity(tripId, placeId, target.id), 'tours.activity.removed', [
placeId,
target.track.place_id,
]);
}, [apply, busy, canEdit, placeId, removing, tripId]);
return {
/** Null while the detail loads; empty, with `unavailable`, when it could not be read. */
activities,
/** The tour has tracks the detail could not be read for, so none can be shown or replaced. */
unavailable,
canEdit,
busy,
assigning,
openAssign: (target: AssignTarget = {}) => {
if (canEdit && !unavailable) setAssigning(target);
},
closeAssign: () => {
if (!busy) setAssigning(null);
},
assign,
removing,
askRemove: (activity: TourActivity) => {
if (canEdit) setRemoving(activity);
},
cancelRemove: () => setRemoving(null),
remove,
};
}
export type TourActivities = ReturnType<typeof useTourActivities>;
@@ -0,0 +1,116 @@
// FE-ACT-ASSIGN-001 to FE-ACT-ASSIGN-005
// ACT-01, the link dialog's state: the day it starts on, the recordings of the
// trip to choose from, what a choice replaces, and a recording imported here
// chosen for the user but never linked by itself.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourActivity, TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TourExecution } from './tourActivityModel';
import type { AssignTarget } from './useTourActivities';
import { useTrackAssignment } from './useTrackAssignment';
const repo = vi.hoisted(() => ({ list: vi.fn() }));
interface ImportOptions {
onImported?: (tours: TourListItem[]) => void;
onToursChanged?: (ids?: number[]) => unknown;
}
const gpx = vi.hoisted(() => ({ options: null as null | ImportOptions }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: repo }));
vi.mock('../useTourGpxImport', () => ({
useTourGpxImport: (options: ImportOptions) => {
gpx.options = options;
return { importing: false, preview: null, pick: vi.fn(), confirm: vi.fn(), cancel: vi.fn(), importFiles: vi.fn() };
},
}));
const tour = { place_id: 7, name: 'Ridge', tour_type: 'hike', origin: 'trek', has_waypoints: true } as TourListItem;
const recording = (placeId: number, name: string, extra: Partial<TourListItem> = {}) =>
({ place_id: placeId, name, tour_type: 'hike', origin: 'gpx', has_waypoints: false, ...extra }) as TourListItem;
const executions: TourExecution[] = [
{ assignmentId: 11, day: null, index: 0 },
{ assignmentId: 31, day: null, index: 2 },
];
const linked = (assignmentId: number | null, trackPlaceId: number, name: string) =>
({
id: trackPlaceId,
assignment_id: assignmentId,
track: { place_id: trackPlaceId, name },
}) as unknown as TourActivity;
beforeEach(() => {
vi.clearAllMocks();
repo.list.mockResolvedValue({ tours: [tour, recording(20, 'Watch'), recording(21, 'Phone', { activity_of: 99 })] });
});
function render(activities: TourActivity[], target: AssignTarget = {}, onToursChanged = vi.fn()) {
return renderHook(() =>
useTrackAssignment({ tripId: 1, tour, executions, activities, target, canEdit: true, onToursChanged })
);
}
describe('useTrackAssignment', () => {
it('FE-ACT-ASSIGN-001: starts on the first day without an actual track, or on the day asked for', async () => {
expect(render([]).result.current.assignmentId).toBe(11);
expect(render([linked(11, 20, 'Watch')]).result.current.assignmentId).toBe(31);
expect(render([linked(11, 20, 'Watch'), linked(31, 21, 'Phone')]).result.current.assignmentId).toBe(11);
const asked = render([linked(31, 21, 'Phone')], { assignmentId: 31 });
expect(asked.result.current.assignmentId).toBe(31);
// A change starts on the track the day has, which therefore replaces nothing.
expect(asked.result.current.selected).toBe(21);
expect(asked.result.current.replaces).toEqual([]);
await waitFor(() => expect(asked.result.current.candidates).not.toBeNull());
});
it('FE-ACT-ASSIGN-002: lists the recordings of the trip, and only a free one can be linked', async () => {
const { result } = render([]);
expect(result.current.candidates).toBeNull();
expect(result.current.ready).toBe(false);
await waitFor(() => expect(result.current.candidates?.map((c) => c.tour.place_id)).toEqual([20, 21]));
expect(repo.list).toHaveBeenCalledWith(1);
act(() => result.current.choose(21));
expect(result.current.choice?.takenBy).toBe('');
expect(result.current.ready).toBe(false);
act(() => result.current.choose(20));
expect(result.current.ready).toBe(true);
expect(result.current.importFields).toEqual({ tour_type: 'hike' });
});
it('FE-ACT-ASSIGN-003: names the track a choice replaces on its day, and none on a day without one', async () => {
const { result } = render([linked(11, 21, 'Phone')], { assignmentId: 11 });
await waitFor(() => expect(result.current.candidates).not.toBeNull());
act(() => result.current.choose(20));
expect(result.current.replaces).toEqual(['Phone']);
act(() => result.current.chooseExecution(null));
expect(result.current.execution.day).toBeNull();
expect(result.current.replaces).toEqual([]);
});
it('FE-ACT-ASSIGN-005: "no day" left with several tracks once their days went: a change starts on the one asked for and names every one it replaces', async () => {
const noDay = [linked(null, 20, 'Watch'), linked(null, 21, 'Phone'), linked(11, 22, 'Bike computer')];
const { result } = render(noDay, { assignmentId: null, activityId: 21 });
await waitFor(() => expect(result.current.candidates).not.toBeNull());
expect(result.current.selected).toBe(21);
expect(result.current.replaces).toEqual(['Watch']);
act(() => result.current.choose(40));
expect(result.current.replaces).toEqual(['Watch', 'Phone']);
});
it('FE-ACT-ASSIGN-004: a recording imported here is chosen and the list read again; the lists are told', async () => {
const onToursChanged = vi.fn();
const { result } = render([], {}, onToursChanged);
await waitFor(() => expect(result.current.candidates).not.toBeNull());
repo.list.mockResolvedValue({ tours: [tour, recording(20, 'Watch'), recording(40, 'Imported')] });
await act(async () => gpx.options?.onToursChanged?.([40]));
expect(onToursChanged).toHaveBeenCalledWith([40]);
await act(async () => gpx.options?.onToursChanged?.());
expect(onToursChanged).toHaveBeenLastCalledWith([]);
act(() => gpx.options?.onImported?.([recording(40, 'Imported')]));
expect(result.current.selected).toBe(40);
await waitFor(() => expect(result.current.choice?.tour.name).toBe('Imported'));
expect(repo.list).toHaveBeenCalledTimes(2);
act(() => gpx.options?.onImported?.([]));
expect(result.current.selected).toBe(40);
});
});
@@ -0,0 +1,111 @@
import type { TourActivity, TourListItem } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { tourRepo } from '../../../repo/tourRepo';
import { useTourGpxImport } from '../useTourGpxImport';
import { executionOf, importFieldsFor, trackCandidates, type TourExecution } from './tourActivityModel';
import type { AssignTarget } from './useTourActivities';
interface TrackAssignmentOptions {
tripId: number;
tour: TourListItem;
executions: readonly TourExecution[];
/** The recordings the tour has now, to preset a change and to name what a choice replaces. */
activities: readonly TourActivity[];
target: AssignTarget;
canEdit: boolean;
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
}
/** The execution the dialog starts on: the one asked for, else the first day without a recording, else none. */
function startingExecution(
executions: readonly TourExecution[],
activities: readonly TourActivity[],
target: AssignTarget
): number | null {
if (target.assignmentId !== undefined) return target.assignmentId;
const recorded = new Set(activities.map((activity) => activity.assignment_id));
return (executions.find((execution) => !recorded.has(execution.assignmentId)) ?? executions[0])?.assignmentId ?? null;
}
/**
* ACT-01, the dialog that assigns a recording: which execution it was, which
* recording of the trip, or a new one through the GPX import (its preview
* preset to the tour's type), and what the choice measures before anything is
* written. Nothing is stored until the user confirms; a recording imported
* here is chosen for them, never assigned.
*/
export function useTrackAssignment({
tripId,
tour,
executions,
activities,
target,
canEdit,
onToursChanged,
}: TrackAssignmentOptions) {
const [assignmentId, setAssignmentId] = useState<number | null>(() =>
startingExecution(executions, activities, target)
);
const onExecution = activities.filter((activity) => activity.assignment_id === assignmentId);
const [selected, setSelected] = useState<number | null>(() => {
const changed = onExecution.find((activity) => activity.id === target.activityId) ?? onExecution[0];
return changed?.track.place_id ?? null;
});
const [tours, setTours] = useState<TourListItem[] | null>(null);
const [reads, setReads] = useState(0);
useEffect(() => {
let cancelled = false;
tourRepo.list(tripId).then(
({ tours: listed }) => {
if (!cancelled) setTours(listed);
},
() => {
if (!cancelled) setTours([]);
}
);
return () => {
cancelled = true;
};
}, [tripId, reads]);
const onImported = useCallback((imported: TourListItem[]) => {
const first = imported[0];
if (first) setSelected(first.place_id);
setReads((count) => count + 1);
}, []);
const gpxImport = useTourGpxImport({
tripId,
canEdit,
onToursChanged: (placeIds) => onToursChanged?.(placeIds ?? []),
onImported,
});
const candidates = useMemo(() => (tours ? trackCandidates(tours, tour.place_id) : null), [tour.place_id, tours]);
const choice = candidates?.find((candidate) => candidate.tour.place_id === selected) ?? null;
// Every recording the execution has now, other than the one chosen, gives way to it: a day has
// one, "no day" can have several once the days they were on are gone, and the server clears them all.
const replaces = onExecution
.filter((activity) => activity.track.place_id !== selected)
.map((activity) => activity.track.name);
return {
executions,
assignmentId,
execution: executionOf(executions, assignmentId),
chooseExecution: setAssignmentId,
/** Null while the trip's tours load. */
candidates,
selected,
choose: setSelected,
choice,
/** The names of the tracks linking replaces; empty when it replaces none. */
replaces,
gpxImport,
importFields: importFieldsFor(tour),
/** A free recording is chosen, so the assignment can be confirmed. */
ready: choice !== null && choice.takenBy === null,
};
}
export type TrackAssignment = ReturnType<typeof useTrackAssignment>;
@@ -15,7 +15,7 @@ vi.mock('./useRouteWays', () => ({
},
}));
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-008
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-009
const LINE: [number, number][] = [
[47.0, 11.0],
@@ -26,9 +26,9 @@ const LINE: [number, number][] = [
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', wayId: 1 },
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', wayId: 3 },
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', sac: 'none', wayId: 1 },
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', sac: 'none', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', sac: 'none', wayId: 3 },
],
};
@@ -49,6 +49,10 @@ const grade = (id: number, mtb: string | null, uphill: string | null = null): To
mtb_scale: mtb,
mtb_scale_uphill: uphill,
mtb_scale_imba: null,
sac_scale: null,
sac_scale_unclear: null,
surface: null,
highway: null,
});
function show(state: Partial<RouteWaysState>, overrides: Partial<TourPlannerController> = {}) {
@@ -66,16 +70,16 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
it('FE-TOUR-BIKECARD-001: the surfaces and kinds of way of the route, by share, with their lengths', () => {
show({});
const card = screen.getByTestId('bike-ways-card');
expect(within(screen.getByTestId('bike-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-surface-gravel')).toHaveTextContent('300 m');
expect(screen.getByTestId('bike-ways-row-way-cyclePath')).toBeInTheDocument();
expect(within(screen.getByTestId('route-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
expect(screen.getByTestId('route-ways-row-surface-gravel')).toHaveTextContent('300 m');
expect(screen.getByTestId('route-ways-row-way-cyclePath')).toBeInTheDocument();
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
expect(card).not.toHaveTextContent('Trail grade');
});
it('FE-TOUR-BIKECARD-002: a row points at its stretches on the map, and lets go when pressed again', () => {
show({});
const row = screen.getByTestId('bike-ways-row-surface-gravel');
const row = screen.getByTestId('route-ways-row-surface-gravel');
fireEvent.click(row);
expect(row).toHaveAttribute('aria-pressed', 'true');
expect(useTourRouteHighlightStore.getState().highlight).toEqual({
@@ -105,15 +109,15 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
unasked: new Set([3]),
},
});
expect(screen.getByTestId('bike-ways-row-trail-s0')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-trail-s2')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-trail-unasked')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-uphill-u3')).toBeInTheDocument();
expect(screen.getByTestId('route-ways-row-trail-s0')).toBeInTheDocument();
expect(screen.getByTestId('route-ways-row-trail-s2')).toBeInTheDocument();
expect(screen.getByTestId('route-ways-row-trail-unasked')).toBeInTheDocument();
expect(screen.getByTestId('route-ways-row-uphill-u3')).toBeInTheDocument();
});
it('FE-TOUR-BIKECARD-005: grades nobody could be asked for are one notice with a retry, not a row for the whole tour', () => {
show({ grades: { status: 'ready', byWay: new Map(), unasked: new Set([1, 2, 3]) } });
expect(screen.queryByTestId('bike-ways-row-trail-unasked')).toBeNull();
expect(screen.queryByTestId('route-ways-row-trail-unasked')).toBeNull();
fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
expect(hook.retry).toHaveBeenCalled();
});
@@ -132,7 +136,7 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
it('FE-TOUR-BIKECARD-007: grades on their way or offline say so in their own section', () => {
const { unmount } = show({ grades: { status: 'loading', byWay: new Map(), unasked: new Set() } });
expect(screen.queryByTestId('bike-ways-row-trail-s0')).toBeNull();
expect(screen.queryByTestId('route-ways-row-trail-s0')).toBeNull();
unmount();
show({ grades: { status: 'offline', byWay: new Map(), unasked: new Set() } });
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
@@ -147,6 +151,11 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
hike.unmount();
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial<TourPlannerController>);
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, profile: 'touring' });
expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, matching: 'bike', withGrades: false });
});
it('FE-TOUR-BIKECARD-009: matches the line as ridden, and asks the trail grades for a mountain bike tour only', () => {
show({}, { bikeProfile: 'mtb' } as Partial<TourPlannerController>);
expect(hook.input).toMatchObject({ active: true, route: LINE, matching: 'bike', withGrades: true });
});
});
+41 -193
View File
@@ -1,5 +1,5 @@
import { AlertTriangle, Info, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { AlertTriangle, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
@@ -8,144 +8,29 @@ import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { CardAction, RailCard, TourNotice } from '../tourParts';
import { bikePaintCss, surfacePaintKey, trailPaintKey, type BikePaintKey } from './bikePaint';
import { trailPaintKey } from './bikePaint';
import {
percent,
RouteWaysProgress,
RouteWaysSource,
ShareBar,
ShareList,
SurfaceSection,
WaySection,
WaysSection,
} from './RouteWaySections';
import {
sharesBy,
stretchesOf,
trailClassOf,
unpavedShareOf,
UNPAVED,
type ClassShare,
type RouteWayEdge,
type RouteWays,
type SurfaceClass,
type TrailClass,
type WayClass,
} from './routeWays';
import { useRouteWays, type TrailGrades } from './useRouteWays';
const percent = (share: number) => `${Math.round(share * 100)} %`;
interface ShareRowProps<C extends string> {
id: string;
row: ClassShare<C>;
label: string;
paint: BikePaintKey;
/** A dot in the class's colour; a kind of way has none. */
dot: boolean;
ways: RouteWays;
inClass: (edge: RouteWayEdge) => boolean;
}
/**
* One class of the route: its share and length, and a switch that points at
* its stretches on the map. Pressed again, or another row pressed, it lets go.
*/
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
const toggle = () =>
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
return (
<li>
<button
type="button"
aria-pressed={shown}
data-testid={`bike-ways-row-${id}`}
onClick={toggle}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
>
<span
aria-hidden="true"
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
style={dot ? { background: bikePaintCss(paint) } : undefined}
/>
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
{label}
</span>
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
{formatDistance(row.lengthM / 1000, unit)}
</span>
<span className="w-10 flex-none text-end font-geist tabular-nums text-content-secondary" style={fs(11)}>
{percent(row.share)}
</span>
</button>
</li>
);
}
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
function ShareBar<C extends string>({ rows, paintOf }: { rows: ClassShare<C>[]; paintOf: (key: C) => BikePaintKey }) {
return (
<span aria-hidden="true" className="flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary">
{rows.map((row) => (
<span
key={row.key}
className="h-full"
style={{ width: `${row.share * 100}%`, background: bikePaintCss(paintOf(row.key)) }}
/>
))}
</span>
);
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5, 'caption')}>
{label}
</span>
{children}
</div>
);
}
function SurfaceSection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, (edge) => edge.surface), [ways]);
return (
<Section label={t('tours.bike.ways.surface')}>
<ShareBar rows={rows} paintOf={surfacePaintKey} />
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`surface-${row.key}`}
row={row}
label={t(`tours.bike.ways.surface.${row.key}`)}
paint={surfacePaintKey(row.key)}
dot
ways={ways}
inClass={(edge) => edge.surface === row.key}
/>
))}
</ul>
</Section>
);
}
function WaySection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<WayClass>(ways, (edge) => edge.way), [ways]);
return (
<Section label={t('tours.bike.ways.way')}>
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`way-${row.key}`}
row={row}
label={t(`tours.bike.ways.way.${row.key}`)}
paint="way"
dot={false}
ways={ways}
inClass={(edge) => edge.way === row.key}
/>
))}
</ul>
</Section>
);
}
/** BIK-05: the trail grades along a mountain bike tour, downhill and uphill apart, the unknown said. */
function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: TrailGrades; onRetry: () => void }) {
const { t } = useTranslation();
@@ -173,46 +58,33 @@ function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: Trai
const unread = noneAnswered ? 'failed' : grades.status;
if (unread !== 'ready')
return (
<Section label={t('tours.bike.trail.title')}>
<WaysSection label={t('tours.bike.trail.title')}>
<GradesStatus status={unread} onRetry={onRetry} />
</Section>
</WaysSection>
);
return (
<>
<Section label={t('tours.bike.trail.title')}>
<WaysSection label={t('tours.bike.trail.title')}>
<ShareBar rows={rows} paintOf={trailPaintKey} />
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`trail-${row.key}`}
row={row}
label={t(`tours.bike.trail.${row.key}`)}
paint={trailPaintKey(row.key)}
dot
ways={ways}
inClass={(edge) => classOf(edge) === row.key}
/>
))}
</ul>
</Section>
<ShareList
ways={ways}
rows={rows}
idPrefix="trail"
classOf={classOf}
labelOf={(key) => t(`tours.bike.trail.${key}`)}
paintOf={trailPaintKey}
/>
</WaysSection>
{uphill.length > 0 && (
<Section label={t('tours.bike.trail.uphillTitle')}>
<ul className="m-0 flex list-none flex-col p-0">
{uphill.map((row) => (
<ShareRow
key={row.key}
id={`uphill-${row.key}`}
row={row}
label={t('tours.bike.trail.uphillStep', { step: row.key.slice(1) })}
paint="way"
dot={false}
ways={ways}
inClass={(edge) => uphillOf(edge) === row.key}
/>
))}
</ul>
</Section>
<WaysSection label={t('tours.bike.trail.uphillTitle')}>
<ShareList
ways={ways}
rows={uphill}
idPrefix="uphill"
classOf={uphillOf}
labelOf={(key) => t('tours.bike.trail.uphillStep', { step: key.slice(1) })}
/>
</WaysSection>
)}
<span className="text-content-faint" style={fs(11, 'body')}>
{t('tours.bike.trail.note')}
@@ -283,15 +155,12 @@ export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
tripId: planner.tripId,
active: planner.isBike && editing,
route,
profile: planner.bikeProfile,
matching: 'bike',
withGrades: planner.bikeProfile === 'mtb',
});
const { ways } = state;
const unpavedShare = useMemo(
() => (ways ? sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')) : []),
[ways]
);
const unpaved = useMemo(() => (ways ? unpavedShareOf(ways) : null), [ways]);
if (!planner.isBike || !editing || state.status === 'idle') return null;
const unpaved = unpavedShare.find((row) => row.key === 'unpaved');
return (
<RailCard
icon={<Layers size={14} strokeWidth={2} />}
@@ -299,40 +168,19 @@ export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
foldId="bike-ways"
aside={
unpaved ? (
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved.share) })}</SoftPill>
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved) })}</SoftPill>
) : undefined
}
>
<div data-testid="bike-ways-card" className="flex min-w-0 flex-col gap-3">
{state.status === 'loading' && (
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{t('tours.bike.ways.loading')}
</span>
)}
{state.status === 'offline' && (
<TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.bike.ways.offline')}</TourNotice>
)}
{state.status === 'failed' && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t('tours.bike.ways.failed')}</span>
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={state.retry}>
{t('common.errorRetry')}
</CardAction>
</span>
</TourNotice>
)}
<RouteWaysProgress state={state} />
{ways && (
<>
{planner.bikeProfile === 'road' && <UnpavedNotice ways={ways} />}
<SurfaceSection ways={ways} />
<WaySection ways={ways} />
{state.grades && <TrailSection ways={ways} grades={state.grades} onRetry={state.retry} />}
<span className="inline-flex gap-1.5 text-content-faint" style={fs(11, 'body')}>
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" />
{t('tours.bike.ways.source')}
</span>
<RouteWaysSource />
</>
)}
</div>
@@ -0,0 +1,228 @@
import { Info } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { formatDistance } from '../../../utils/units';
import { fs } from '../../shared/DialogShell';
import { ReadStatusNotice } from '../ReadStatusNotice';
import { InfoDot } from '../tourParts';
import { bikePaintCss, surfacePaintKey, type BikePaintKey } from './bikePaint';
import {
sharesBy,
stretchesOf,
type ClassShare,
type RouteWayEdge,
type RouteWays,
type SurfaceClass,
type WayClass,
} from './routeWays';
import type { RouteWaysState } from './useRouteWays';
/**
* BIK-01 to BIK-05, RUN-02: the pieces a card about what a route runs on is
* built from, shared by the bike tour's card and the run's: a class's share
* as a bar and as rows that point at its stretches on the map, the surfaces
* and kinds of way, and how the reading went.
*/
export const percent = (share: number) => `${Math.round(share * 100)} %`;
interface ShareRowProps<C extends string> {
id: string;
row: ClassShare<C>;
label: string;
paint: BikePaintKey;
/** A dot in the class's colour; a kind of way has none. */
dot: boolean;
ways: RouteWays;
inClass: (edge: RouteWayEdge) => boolean;
}
/**
* One class of the route: its share and length, and a switch that points at
* its stretches on the map. Pressed again, or another row pressed, it lets go.
*/
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
const toggle = () =>
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
return (
<li>
<button
type="button"
aria-pressed={shown}
data-testid={`route-ways-row-${id}`}
onClick={toggle}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
>
<span
aria-hidden="true"
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
style={dot ? { background: bikePaintCss(paint) } : undefined}
/>
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
{label}
</span>
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
{formatDistance(row.lengthM / 1000, unit)}
</span>
<span className="w-10 flex-none text-end font-geist tabular-nums text-content-secondary" style={fs(11)}>
{percent(row.share)}
</span>
</button>
</li>
);
}
/**
* The classes of the route as rows, the largest first. With `paintOf` each
* row has a dot in its class's colour and points at its stretches in it; a
* kind of way has no colour and is pointed at in the accent.
*/
export function ShareList<C extends string>({
ways,
rows,
idPrefix,
classOf,
labelOf,
paintOf,
}: {
ways: RouteWays;
rows: ClassShare<C>[];
idPrefix: string;
classOf: (edge: RouteWayEdge) => C;
labelOf: (key: C) => string;
paintOf?: (key: C) => BikePaintKey;
}) {
return (
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`${idPrefix}-${row.key}`}
row={row}
label={labelOf(row.key)}
paint={paintOf ? paintOf(row.key) : 'way'}
dot={!!paintOf}
ways={ways}
inClass={(edge) => classOf(edge) === row.key}
/>
))}
</ul>
);
}
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
export function ShareBar<C extends string>({
rows,
paintOf,
}: {
rows: ClassShare<C>[];
paintOf: (key: C) => BikePaintKey;
}) {
return (
<span aria-hidden="true" className="flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary">
{rows.map((row) => (
<span
key={row.key}
className="h-full"
style={{ width: `${row.share * 100}%`, background: bikePaintCss(paintOf(row.key)) }}
/>
))}
</span>
);
}
/** A labelled part of the card; `info` puts what the part means behind a dot rather than in a line of its own. */
export function WaysSection({
label,
info,
children,
}: {
label: string;
info?: { label: string; description: string };
children: ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<span className="flex min-h-5 min-w-0 items-center gap-1">
<span
className="min-w-0 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
style={fs(9.5, 'caption')}
>
{label}
</span>
{info && <InfoDot label={info.label} description={info.description} />}
</span>
{children}
</div>
);
}
const surfaceOf = (edge: RouteWayEdge) => edge.surface;
const wayClassOf = (edge: RouteWayEdge) => edge.way;
/** The surfaces under the route, the unknown named as such. */
export function SurfaceSection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, surfaceOf), [ways]);
return (
<WaysSection label={t('tours.bike.ways.surface')}>
<ShareBar rows={rows} paintOf={surfacePaintKey} />
<ShareList
ways={ways}
rows={rows}
idPrefix="surface"
classOf={surfaceOf}
labelOf={(key) => t(`tours.bike.ways.surface.${key}`)}
paintOf={surfacePaintKey}
/>
</WaysSection>
);
}
/** The kinds of way the route takes. */
export function WaySection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<WayClass>(ways, wayClassOf), [ways]);
return (
<WaysSection label={t('tours.bike.ways.way')}>
<ShareList
ways={ways}
rows={rows}
idPrefix="way"
classOf={wayClassOf}
labelOf={(key) => t(`tours.bike.ways.way.${key}`)}
/>
</WaysSection>
);
}
/** How reading the ways went while there are none to show: on its way, offline, or failed with a way to ask again. */
export function RouteWaysProgress({ state }: { state: Pick<RouteWaysState, 'status'> & { retry: () => void } }) {
const { t } = useTranslation();
return (
<ReadStatusNotice
status={state.status}
messages={{
loading: t('tours.bike.ways.loading'),
offline: t('tours.bike.ways.offline'),
failed: t('tours.bike.ways.failed'),
}}
onRetry={state.retry}
/>
);
}
/** Where what the card shows comes from, and that a gap in it is unknown, not fine. */
export function RouteWaysSource() {
const { t } = useTranslation();
return (
<span className="inline-flex gap-1.5 text-content-faint" style={fs(11, 'body')}>
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" />
{t('tours.bike.ways.source')}
</span>
);
}
+21 -2
View File
@@ -1,8 +1,8 @@
import { useMapPaint } from '../../Map/mapPaint';
import type { SurfaceClass, TrailClass } from './routeWays';
import type { SacClass, SurfaceClass, TrailClass } from './routeWays';
/**
* The colours of a bike tour's surfaces and trail grades (styles/bike.css),
* The colours of a bike tour's or a run's surfaces and grades (styles/bike.css),
* by name. The cards take the CSS variable, which follows the theme on its
* own; the map reads them resolved (`useBikePaint`). A kind of way has no
* colour of its own: it is pointed at in the accent.
@@ -30,6 +30,25 @@ export function trailPaintKey(key: TrailClass): BikePaintKey {
return key === 'none' || key === 'unasked' ? 'unknown' : key;
}
/**
* RUN-02: the paint of a hiking grade, on the same scale from easy to hard as
* the trail grades (T1 as S0, T5 and up as S4), so a map never shows two
* colour scales for one idea; a way without a grade in the grey of the unknown.
*/
const SAC_PAINT: Record<SacClass, BikePaintKey> = {
t1: 's0',
t2: 's1',
t3: 's2',
t4: 's3',
t5: 's4',
t6: 's4',
none: 'unknown',
};
export function sacPaintKey(key: SacClass): BikePaintKey {
return SAC_PAINT[key];
}
/** The paint of a surface class. */
export function surfacePaintKey(key: SurfaceClass): BikePaintKey {
return key;
@@ -1,8 +1,9 @@
import type { RouteHighlight } from '../../../store/tourRouteHighlightStore';
import type { BikePaintKey } from './bikePaint';
type Line = [number, number][];
type LineColor = { line: string; casing: string } | undefined;
/** The route paints resolved for the map, by key: the bike's and the hike's, with the accent and the rim. */
type RoutePaint = Readonly<Record<string, string>> & { readonly way: string; readonly casing: string };
/**
* The planner map's lines with a highlight laid over them: the route's own
@@ -15,13 +16,16 @@ export function withRouteHighlight(
route: Line[] | null,
walking: boolean[] | null,
colors: LineColor[] | null,
paint: Record<BikePaintKey, string>
paint: RoutePaint
): { route: Line[] | null; walking: boolean[] | null; colors: LineColor[] | null } {
if (!highlight?.lines.length || !route?.length) return { route, walking, colors };
const color = { line: paint[highlight.paint as BikePaintKey] ?? paint.way, casing: paint.casing };
const colorOf = (index: number) => ({
line: paint[highlight.paints?.[index] ?? highlight.paint] ?? paint.way,
casing: paint.casing,
});
return {
route: [...route, ...highlight.lines],
walking: walking ? [...walking, ...highlight.lines.map(() => false)] : null,
colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map(() => color)],
colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map((_, index) => colorOf(index))],
};
}
@@ -1,10 +1,19 @@
import { describe, expect, it } from 'vitest';
import { encodePolyline } from '../../../../tests/helpers/polyline';
import { bikePaintCss, surfacePaintKey, trailPaintKey } from './bikePaint';
import { bikePaintCss, sacPaintKey, surfacePaintKey, trailPaintKey } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
import { routeWaysFrom, sharesBy, stretchesOf, trailClassOf, wayIdsOf, type RouteWays } from './routeWays';
import {
routeWaysFrom,
sacClassOf,
sharesBy,
stretchesOf,
trailClassOf,
unpavedShareOf,
wayIdsOf,
type RouteWays,
} from './routeWays';
// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-010
// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-014
const LINE: [number, number][] = [
[47.0, 11.0],
@@ -65,6 +74,20 @@ describe('routeWaysFrom (BIK-01 to BIK-04)', () => {
]);
});
it('FE-TOUR-WAYS-011: reads the hiking grade of each edge, and no grade, a 0 or anything else as none (RUN-02)', () => {
const ways = routeWaysFrom(
answer([
edge(0, 1, { use: 'path', sac_scale: 1 }),
edge(1, 2, { use: 'path', sac_scale: 2 }),
edge(2, 3, { use: 'road', sac_scale: 0 }),
edge(3, 4, { use: 'road' }),
])
);
expect(ways?.edges.map((e) => e.sac)).toEqual(['t1', 't2', 'none', 'none']);
expect([3, 4, 5, 6].map(sacClassOf)).toEqual(['t3', 't4', 't5', 't6']);
for (const value of [7, -1, 2.5, '2', null]) expect(sacClassOf(value)).toBe('none');
});
it('FE-TOUR-WAYS-003: drops an edge without its place on the line, and is null for no answer of that kind', () => {
const ways = routeWaysFrom(
answer([edge(0, 1), { ...edge(1, 2), end_shape_index: 9 }, { ...edge(2, 1) }, { ...edge(2, 3), length: -1 }])
@@ -80,10 +103,10 @@ describe('routeWaysFrom (BIK-01 to BIK-04)', () => {
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 300, surface: 'paved', way: 'road', wayId: 1 },
{ from: 1, to: 2, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 3, to: 4, lengthM: 0, surface: 'ground', way: 'path', wayId: null },
{ from: 0, to: 1, lengthM: 300, surface: 'paved', way: 'road', sac: 'none', wayId: 1 },
{ from: 1, to: 2, lengthM: 100, surface: 'gravel', way: 'track', sac: 'none', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'gravel', way: 'track', sac: 'none', wayId: 2 },
{ from: 3, to: 4, lengthM: 0, surface: 'ground', way: 'path', sac: 'none', wayId: null },
],
};
@@ -96,6 +119,11 @@ describe('what the line runs on (BIK-01 to BIK-05)', () => {
expect(sharesBy({ line: LINE, edges: [] }, (e) => e.surface)).toEqual([]);
});
it('FE-TOUR-WAYS-013: the share known to be unpaved, and none where nothing is', () => {
expect(unpavedShareOf(WAYS)).toBe(0.4);
expect(unpavedShareOf({ line: LINE, edges: [WAYS.edges[0]] })).toBeNull();
});
it('FE-TOUR-WAYS-005: the stretches of a class, neighbours joined into one line each', () => {
expect(stretchesOf(WAYS, (e) => e.surface === 'gravel')).toEqual([LINE.slice(1, 4)]);
expect(stretchesOf(WAYS, (e) => e.surface !== 'gravel')).toEqual([LINE.slice(0, 2), LINE.slice(3, 5)]);
@@ -126,6 +154,12 @@ describe('the paints and the highlight (BIK-01 to BIK-05)', () => {
expect(bikePaintCss('s1')).toBe('var(--bike-trail-s1)');
});
it('FE-TOUR-WAYS-012: a hiking grade takes the paint of the trail grade it matches, T5 and up the hardest (RUN-02)', () => {
const grades = ['t1', 't2', 't3', 't4', 't5', 't6'] as const;
expect(grades.map(sacPaintKey)).toEqual(['s0', 's1', 's2', 's3', 's4', 's4']);
expect(sacPaintKey('none')).toBe('unknown');
});
const paint = { gravel: '#a0522d', way: '#111', casing: '#fff' } as Parameters<typeof withRouteHighlight>[4];
it('FE-TOUR-WAYS-009: lays the highlighted stretches over the route in their paint, solid', () => {
@@ -152,4 +186,19 @@ describe('the paints and the highlight (BIK-01 to BIK-05)', () => {
expect(withRouteHighlight({ id: 'x', lines: [], paint: 'gravel' }, route, null, null, paint).route).toBe(route);
expect(withRouteHighlight({ id: 'x', lines: [LINE], paint: 'gravel' }, null, null, null, paint).route).toBeNull();
});
it('FE-TOUR-WAYS-014: lines of different paints each take their own, an unknown key the accent (MAP-03)', () => {
const drawn = withRouteHighlight(
{ id: 'sac', lines: [LINE.slice(0, 2), LINE.slice(1, 3), LINE.slice(2)], paint: 'gravel', paints: ['way', 'nope'] },
[LINE],
null,
null,
paint
);
expect(drawn.colors?.slice(1)).toEqual([
{ line: '#111', casing: '#fff' },
{ line: '#111', casing: '#fff' },
{ line: '#a0522d', casing: '#fff' },
]);
});
});
+30 -4
View File
@@ -2,10 +2,11 @@ import type { TourTrailGrade } from '@trek/shared';
import { decodePolyline } from '../../Map/transitGeometry';
/**
* BIK-01 to BIK-05: what a bike tour runs on, read from the router's own
* description of the ways along its line (Valhalla's trace attributes): the
* surface, the kind of way, and the OpenStreetMap way behind each stretch.
* Pure, so the classes and their shares are tested apart from the request.
* BIK-01 to BIK-05, RUN-02: what a bike tour or a run runs on, read from the
* router's own description of the ways along its line (Valhalla's trace
* attributes): the surface, the kind of way, the hiking grade, and the
* OpenStreetMap way behind each stretch. Pure, so the classes and their
* shares are tested apart from the request.
*/
/** Surfaces from smooth to rough; `unknown` where the data names none. */
@@ -21,6 +22,13 @@ export type WayClass = 'cyclePath' | 'cycleLane' | 'quiet' | 'road' | 'track' |
*/
export type TrailClass = 's0' | 's1' | 's2' | 's3' | 's4' | 'none' | 'unasked';
/**
* RUN-02: sac_scale steps, T1 to T6, as OpenStreetMap mappers gave them to a
* way; `none` where the way has no grade (the router's 0), which is unknown,
* never easy.
*/
export type SacClass = 't1' | 't2' | 't3' | 't4' | 't5' | 't6' | 'none';
/** Surfaces a road bike's rider calls unpaved (BIK-02): what is no paving of any kind. */
export const UNPAVED: ReadonlySet<SurfaceClass> = new Set(['compacted', 'gravel', 'ground']);
@@ -31,6 +39,7 @@ export interface RouteWayEdge {
lengthM: number;
surface: SurfaceClass;
way: WayClass;
sac: SacClass;
/** The OpenStreetMap way, where the router names it. */
wayId: number | null;
}
@@ -89,6 +98,7 @@ interface TraceEdge {
use?: unknown;
road_class?: unknown;
cycle_lane?: unknown;
sac_scale?: unknown;
length?: unknown;
way_id?: unknown;
begin_shape_index?: unknown;
@@ -98,6 +108,13 @@ interface TraceEdge {
const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null);
/** The router's sac_scale, 1 to 6, as its step; 0, no value or anything else is no grade. */
export function sacClassOf(value: unknown): SacClass {
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6
? (`t${value}` as SacClass)
: 'none';
}
/**
* The router's answer read into stretches, or null when it is no answer of
* that kind. Edge lengths come in kilometres; an edge without its place on the
@@ -122,6 +139,7 @@ export function routeWaysFrom(answer: unknown): RouteWays | null {
lengthM: km * 1000,
surface,
way: wayOf(text(raw.use), text(raw.road_class), text(raw.cycle_lane)),
sac: sacClassOf(raw.sac_scale),
wayId: wayId && wayId > 0 ? wayId : null,
});
}
@@ -153,6 +171,14 @@ export function sharesBy<C extends string>(ways: RouteWays, classOf: (edge: Rout
.sort((a, b) => b.lengthM - a.lengthM);
}
/** The share of the line known to be unpaved, 0 to 1, or null when none of it is. */
export function unpavedShareOf(ways: RouteWays): number | null {
const row = sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')).find(
(share) => share.key === 'unpaved'
);
return row ? row.share : null;
}
/** The stretches of the line in one class, neighbours joined, each as its own line for the map. */
export function stretchesOf(ways: RouteWays, inClass: (edge: RouteWayEdge) => boolean): [number, number][][] {
const stretches: [number, number][][] = [];
@@ -8,7 +8,7 @@ const LINE: [number, number][] = [
[47.01, 11],
];
describe('useHighlightedRoute (BIK-01 to BIK-05)', () => {
describe('useHighlightedRoute (BIK-01 to BIK-05, MAP-03)', () => {
it('lays the highlight over the route while shown, and leaves the route alone otherwise', () => {
act(() => useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [LINE], paint: 'gravel' }));
const shown = renderHook(() => useHighlightedRoute(true, [LINE], null, null));
@@ -19,4 +19,19 @@ describe('useHighlightedRoute (BIK-01 to BIK-05)', () => {
act(() => useTourRouteHighlightStore.getState().clear());
expect(shown.result.current.route).toEqual([LINE]);
});
it('draws the SAC grades of a hike in their own paints beside the bike paints (MAP-03)', () => {
act(() =>
useTourRouteHighlightStore
.getState()
.show({ id: 'sac', lines: [LINE, LINE], paint: 'unknown', paints: ['t3', 'unknown'] })
);
const { result } = renderHook(() => useHighlightedRoute(true, [LINE], null, null));
expect(result.current.route).toHaveLength(3);
expect(result.current.colors?.slice(1)).toEqual([
{ line: expect.any(String), casing: expect.any(String) },
{ line: expect.any(String), casing: expect.any(String) },
]);
act(() => useTourRouteHighlightStore.getState().clear());
});
});
@@ -1,4 +1,6 @@
import { useMemo } from 'react';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { useHikePaint } from '../hike/hikePaint';
import { useBikePaint } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
@@ -7,12 +9,14 @@ type Walking = Parameters<typeof withRouteHighlight>[2];
type Colors = Parameters<typeof withRouteHighlight>[3];
/**
* BIK-01 to BIK-05: the planner map's route with the stretches a bike tour's
* card points at laid over it, while `shown` (the route being edited is on
* the map); otherwise the lines as they came.
* BIK-01 to BIK-05 and MAP-03: the planner map's route with the stretches a
* bike tour's or a hike's card points at laid over it, while `shown` (the
* tour's own line is on the map); otherwise the lines as they came.
*/
export function useHighlightedRoute(shown: boolean, route: Lines, walking: Walking, colors: Colors) {
const highlight = useTourRouteHighlightStore((state) => state.highlight);
const paint = useBikePaint();
const bike = useBikePaint();
const hike = useHikePaint();
const paint = useMemo(() => ({ ...bike, ...hike }), [bike, hike]);
return withRouteHighlight(shown ? highlight : null, route, walking, colors, paint);
}

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