Compare commits

...

29 Commits

Author SHA1 Message Date
Maurice 71fe69c1f1 feat(tours): mark the best known places along a city walk
A search for sights along a tour now also asks the places index for the best
known places near the line, ranked by how often their Wikipedia articles are
read (QRank). A row the index already found is marked as a highlight; a
famous sight no kind names, like a square, is added as a sight of its own.
Stations, hotels and restaurants rank a row they match but are never added.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A walk's parks are parks and gardens only, and its museums are museums
and galleries only, no longer the beaches, summits, theatres and cinemas of
the map's explore buttons; a water park the index files under parks is not
one of them. The index also answers markets and summits. The explore
buttons keep their single request, and a search of several kinds that
would exceed the index limits is asked one kind at a time instead of being
refused. The MCP tool search_tour_pois and its wiki row describe the walk
kinds and the new unavailable list, and the OVERPASS_TIMEOUT_MS row says
the search along a tour keeps its own shorter deadline.
2026-10-20 07:09:56 +00:00
Maurice 9ed944c1e6 fix(tours): city walk gates: entity collection, formatting, a walk's name hint and the tests' visits repository
The day assignments entity carries the station visits that hang off it, as
the generator writes it. A city walk gets its own placeholder for an untitled
name, the tour service tests hand in the visits repository the service now
takes, and the files the walk touched are formatted. The theme icons of the
place search overlap by their start margin, so they stack the same way in
both reading directions.
2026-10-20 07:09:56 +00:00
Maurice 2955c61c86 feat(tours): a city walk's arrivals against its stops' opening hours, and a compact place search (CW-04, CW-05)
Opened from a day with a time, each stop shows its estimated arrival: the
start, the walking time shared out along the route, and the stays before
it, at the earliest while a stay is unknown. A stop whose opening hours say
it is closed then, or closes before the stay ends, says so with the times;
hours that cannot be read are shown as they are, and a stop without hours is
unknown, never open. The themes are a two by two grid and the categories one
line with a list to tick, so the search no longer fills the rail. The button
that discards a draft for a new tour says so in two words.
2026-10-20 07:09:56 +00:00
Maurice c97105ce61 feat(tours): fill a city walk from a theme: culture, parks, food or a bit of everything (CW-05)
The search along the route offers a walk's own categories (sights, museums,
parks, views, coffee shops, places to eat, markets) and four themes that set
them at once. A found place says which theme brought it, its source and how
far it lies from the line, straight across; taken in as a stop, it keeps its
name and opening hours. Nothing is taken in without the existing confirm.
2026-10-20 07:09:56 +00:00
Maurice 290961cced feat(tours): check a city walk's stops off for the day it is on (CW-03)
The walk's detail opened from a day lists its stops in order, each with its
stay, importance and protection, and marks one visited or skipped for that
day, or takes it back. The day row shows how many were visited. Marks reach
the other travellers at once and change no route, place or order; a copy or
another day starts with none.
2026-10-20 07:09:56 +00:00
Maurice 4c594876ef feat(tours): plan a city walk with its stops, stays, importance and protection (CW-01, CW-02, CW-10)
City walk is a type of the planner: routed on foot without a hiking limit, on
the standard map. Each stop is edited in its own dialog, its name, its stay
(empty is not known, 0 a point passed through), its importance and whether a
proposed change must leave it; none of it plans the route again. The walk's
time is walking plus the stays, at least that much while a stay is unknown,
with no breaks on top.
2026-10-20 07:09:56 +00:00
Maurice d5d2d6b25e feat(tours): city walks in the contract and on the server (CW-01, CW-02, CW-03, CW-09, CW-10)
A tour can be a city walk. Its points are stations that keep a key across
saves, a name, a stay (0 for a point passed through, empty when unknown), an
importance and a protection; the visits of a walk's stations are kept per day
assignment, so another day or a copy starts with nothing visited. A station
that is gone takes its visits with it. An assistant reads how far a walk got
on a day, what is open and which times are known.
2026-10-20 07:09:56 +00:00
284 changed files with 24411 additions and 1348 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 15,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 41,
+6
View File
@@ -571,6 +571,12 @@
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
},
{
"feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk",
"hooks": ["src/components/Planner/useTransitLegPlanner.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
},
{
"feature": "Reservation form incl. hotel accommodation creation",
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
+2 -2
View File
@@ -12,7 +12,7 @@
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
"src/components/Planner/DayPlanSidebar.tsx": 2971,
"src/components/Planner/DayPlanSidebar.tsx": 2969,
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
"src/components/Planner/PlaceFormModal.tsx": 1250,
"src/components/Planner/PlaceInspector.tsx": 1304,
@@ -24,7 +24,7 @@
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1142,
"src/pages/TripPlannerPage.tsx": 1141,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
+20
View File
@@ -0,0 +1,20 @@
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
import apiClient from './client';
/** Where one station of a walk is marked for one day; a write queued offline replays to the same path. */
export function tourVisitPath(tripId: number | string, assignmentId: number, stationKey: string): string {
return `/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`;
}
/** CW-03: the stations of a trip's city walks visited or skipped on their days. */
export const tourVisitsApi = {
list: (tripId: number | string): Promise<TourVisitListResponse> =>
apiClient.get(`/trips/${tripId}/tour-visits`).then((r) => r.data),
update: (
tripId: number | string,
assignmentId: number,
stationKey: string,
body: TourVisitUpdateRequest
): Promise<TourVisitListResponse> =>
apiClient.put(tourVisitPath(tripId, assignmentId, stationKey), body).then((r) => r.data),
};
+20
View File
@@ -0,0 +1,20 @@
import type { WalkImportPreviewRequest, WalkImportPreviewResponse } from '@trek/shared';
import apiClient from './client';
/**
* CW-07: a Google Maps walking route read into its stops. The server may
* follow a short link a few hops within its own deadline, so the call waits
* a little longer than that before it gives up.
*/
const WALK_IMPORT_TIMEOUT_MS = 20_000;
export const tourWalkImportApi = {
preview: (
tripId: number | string,
body: WalkImportPreviewRequest,
signal?: AbortSignal
): Promise<WalkImportPreviewResponse> =>
apiClient
.post(`/trips/${tripId}/tours/import/google-maps/preview`, body, { signal, timeout: WALK_IMPORT_TIMEOUT_MS })
.then((r) => r.data),
};
+2
View File
@@ -19,6 +19,8 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
'tours:changed',
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
'tours:visits',
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
'collab:note:updated',
+7 -1
View File
@@ -1,4 +1,4 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, type LucideIcon } from 'lucide-react'
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, type LucideIcon } from 'lucide-react'
import type { TourPoiCategory } from '@trek/shared'
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
// The road-trip kinds take their colour from the one place that defines it, so the
@@ -62,6 +62,12 @@ export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
slipway: { key: 'slipway', labelKey: 'tours.poi.cat.slipway', Icon: Anchor, color: coreColor('museum') },
boat_rental: { key: 'boat_rental', labelKey: 'tours.poi.cat.boat_rental', Icon: Sailboat, color: coreColor('cafe') },
bike_repair: { key: 'bike_repair', labelKey: 'tours.poi.cat.bike_repair', Icon: Wrench, color: coreColor('activity') },
// CW-05: a city walk's own, each in the colour of the explore category it comes from.
museum: { key: 'museum', labelKey: 'tours.poi.cat.museum', Icon: Landmark, color: coreColor('museum') },
park: { key: 'park', labelKey: 'tours.poi.cat.park', Icon: Trees, color: coreColor('nature') },
cafe: { key: 'cafe', labelKey: 'tours.poi.cat.cafe', Icon: Coffee, color: coreColor('cafe') },
restaurant: { key: 'restaurant', labelKey: 'tours.poi.cat.restaurant', Icon: Utensils, color: coreColor('restaurant') },
market: { key: 'market', labelKey: 'tours.poi.cat.market', Icon: Store, color: coreColor('shopping') },
}
// A Map rather than an object, so a category named after an Object.prototype member
@@ -8,7 +8,8 @@ import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
import { movingTimeLabel } from '../Tours/tourFacts';
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
import { formatPlannedTourDuration, plannedTotalText, type TourPlannedTimes } from '../Tours/tourPresentation';
import { WalkProgressPill } from '../Tours/walk/WalkStations';
import { TimePill } from './planParts';
type DayRowTourTimes = TourPlannedTimes & { tour?: TourListItem };
@@ -19,6 +20,8 @@ interface DayPlanPlaceSublineProps {
tourTimes: DayRowTourTimes | undefined;
isTourPlace: boolean;
timeFormat: string;
/** CW-03: the row's day assignment, which a city walk's progress belongs to. */
assignmentId?: number;
}
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
@@ -57,9 +60,9 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
<TimePill>
<span
data-testid="trip-plan-tour-planned-duration"
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${plannedTotalText(tourTimes, t)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
>
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}
{t('tours.planner.plannedShort')}: {plannedTotalText(tourTimes, t, true)}
{manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</TimePill>
@@ -90,7 +93,14 @@ function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace:
// Second line of a place row in the day plan: time, tour facts or planned
// tour durations, then the clamped description.
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat }: DayPlanPlaceSublineProps) {
export function DayPlanPlaceSubline({
place,
cat,
tourTimes,
isTourPlace,
timeFormat,
assignmentId,
}: DayPlanPlaceSublineProps) {
const { locale } = useTranslation();
const dayRowTour = tourTimes?.tour;
const description = place.description || place.address || cat?.name;
@@ -106,6 +116,7 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
</TimePill>
)}
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{dayRowTour && <WalkProgressPill tour={dayRowTour} assignmentId={assignmentId} />}
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
</div>
);
@@ -1,16 +1,28 @@
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
// and that it falls back to the plain Place row when it is off. The mocks are the
// ones DayPlanSidebar.test.tsx renders the sidebar with.
// CW-08: public transit between two city walks from the day plan's connector.
import userEvent from '@testing-library/user-event';
import type { TourListItem } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
import {
buildAssignment,
buildDay,
buildPlace,
buildReservation,
buildTrip,
buildUser,
} from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen } from '../../../tests/helpers/render';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import type { Reservation } from '../../types';
import { tourPlannedTimes } from '../Tours/tourPresentation';
import DayPlanSidebar from './DayPlanSidebar';
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
const mockDayNotesState = vi.hoisted(() => ({
@@ -56,12 +68,14 @@ vi.mock('../Map/RouteCalculator', () => ({
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
optimizeRoute: vi.fn().mockImplementation((places) => places),
// One leg per waypoint gap; the connector between two stops reads distanceText.
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
// One leg per waypoint gap, carrying its two ends as the real one does; the connector between two stops reads distanceText.
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints: { lat: number; lng: number }[] = []) =>
Promise.resolve({
distanceText: '2 km',
durationText: '10 min',
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
legs: waypoints.slice(1).map((to, index) => ({
from: [waypoints[index]?.lat, waypoints[index]?.lng],
to: [to.lat, to.lng],
distanceText: '2 km',
durationText: '10 min',
drivingText: '10 min',
@@ -71,6 +85,13 @@ vi.mock('../Map/RouteCalculator', () => ({
),
}));
// CW-08: a walk's stops are read for the names of a transit leg's two ends.
const mockTourDetail = vi.hoisted(() => vi.fn());
vi.mock('../../repo/tourRepo', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
return { ...actual, tourRepo: { ...actual.tourRepo, detail: mockTourDetail } };
});
// PlaceAvatar needs IntersectionObserver
class MockIO {
observe = vi.fn();
@@ -160,7 +181,14 @@ describe('DayPlanSidebar', () => {
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false };
const times = {
walkingMinutes: 60,
breakMinutes: 35,
plannedTotalMinutes: 95,
manuallyOverridden: false,
walk: null,
atLeast: false,
};
const props = makeDefaultProps({
days: [day],
places: [place],
@@ -189,6 +217,22 @@ describe('DayPlanSidebar', () => {
);
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override');
// CW-02: a city walk whose stay is not entered yet plans at least its known total.
view.rerender(
<DayPlanSidebar
{...{
...props,
plannedTourDurations: new Map([
[place.id, { ...times, breakMinutes: null, plannedTotalMinutes: 135, atLeast: true }],
]),
}}
/>
);
const walkTotal = screen.getByTestId('trip-plan-tour-planned-duration');
expect(walkTotal).toHaveTextContent('Planned: ≥ 2 h 15 min');
expect(walkTotal).toHaveAccessibleName('Planned total duration: at least 2 h 15 min');
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
view.rerender(<DayPlanSidebar {...props} />);
expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull();
@@ -305,3 +349,214 @@ describe('DayPlanSidebar', () => {
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
});
});
// CW-08: the old town walk ends at the bridge, the harbour walk starts at the fish market.
const TOURS_ON = [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }];
const oldTown = buildPlace({
id: 51,
name: 'Old town',
tour_place_id: 51,
lat: 52.52,
lng: 13.4,
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
});
const harbour = buildPlace({
id: 52,
name: 'Harbour',
tour_place_id: 52,
lat: 52.5,
lng: 13.45,
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
});
const cafe = buildPlace({ id: 53, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
function walkTour(place: { id: number; name: string }, overrides: Partial<TourListItem> = {}): TourListItem {
return {
place_id: place.id,
name: place.name,
tour_type: 'walk',
distance: 3,
elevation_gain: null,
elevation_loss: null,
duration: 90,
break_additional_minutes: null,
planned_duration_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: true,
caution: false,
station_count: 2,
station_stay_minutes: 45,
station_stays_unknown: 0,
...overrides,
} as TourListItem;
}
function walkDayProps(extra: { reservations?: Reservation[]; withCafe?: boolean; onPlanTransitLeg?: () => void }) {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
const assignments = {
'10': [
buildAssignment({
id: 201,
day_id: 10,
order_index: 0,
tour_place_id: 51,
place: { ...oldTown, place_time: '10:00' },
}),
buildAssignment({
id: 202,
day_id: 10,
order_index: 1,
tour_place_id: 52,
place: { ...harbour, place_time: '13:00' },
}),
...(extra.withCafe ? [buildAssignment({ id: 203, day_id: 10, order_index: 2, place: cafe })] : []),
],
};
const tours = [walkTour(oldTown), walkTour(harbour)];
return makeDefaultProps({
days: [day],
places: [oldTown, harbour, cafe],
assignments,
selectedDayId: 10,
routeShown: true,
tourPlaceIds: new Set([51, 52]),
plannedTourDurations: new Map(tours.map((tour) => [tour.place_id, { ...tourPlannedTimes(tour), tour }])),
reservations: extra.reservations ?? [],
onPlanTransitLeg: extra.onPlanTransitLeg,
});
}
/** A journey saved from the old town walk's end to the harbour walk's start, arriving at `arrives`. */
function savedJourney(arrives: string): Reservation {
return {
...buildReservation({
id: 300,
type: 'transit',
title: 'Old bridge → Fish market',
reservation_time: '2025-06-01T12:20',
reservation_end_time: `2025-06-01T${arrives}`,
day_id: 10,
}),
endpoints: [
{
role: 'from',
sequence: 0,
name: 'Old bridge',
lat: 52.51,
lng: 13.42,
local_date: '2025-06-01',
local_time: '12:20',
},
{
role: 'to',
sequence: 1,
name: 'Fish market',
lat: 52.5,
lng: 13.45,
local_date: '2025-06-01',
local_time: arrives,
},
],
metadata: {
transit: {
provider: 'transitous',
duration: 1800,
transfers: 0,
legs: [{ mode: 'TRAM', line: 'M1', duration: 1500 }],
},
},
} as unknown as Reservation;
}
function contextMenu() {
return within(document.querySelector('[style*="z-index: 999999"]') as HTMLElement);
}
describe('DayPlanSidebar, public transit between two city walks (CW-08)', () => {
beforeEach(() => {
useAddonStore.setState({ addons: TOURS_ON });
mockTourDetail.mockImplementation((_tripId: number, placeId: number) =>
Promise.resolve({
tour: walkTour(placeId === 51 ? oldTown : harbour),
waypoints:
placeId === 51
? [
{ lat: 52.52, lng: 13.4, role: 'start', sequence: 0, key: 'a1', name: 'Town hall', stay_minutes: 30 },
{ lat: 52.51, lng: 13.42, role: 'end', sequence: 1, key: 'a2', name: 'Old bridge', stay_minutes: 15 },
]
: [
{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0, key: 'b1', name: 'Fish market' },
{ lat: 52.49, lng: 13.46, role: 'end', sequence: 1, key: 'b2', name: 'Lighthouse' },
],
})
);
});
it('CW-08: the connector between two walks opens the search from the last stop to the first, when the first walk ends', async () => {
const user = userEvent.setup();
const onPlanTransitLeg = vi.fn();
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg })} />);
await user.click(await screen.findByLabelText('Change travel mode'));
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
// The walk's end at 10:00 plus 90 min walking and 45 min of stays; nothing is saved.
await waitFor(() =>
expect(onPlanTransitLeg).toHaveBeenCalledWith({
dayId: 10,
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
time: '12:15',
walk: {
dayId: 10,
fromWalk: 'Old town',
toWalk: 'Harbour',
ends: '12:15',
endsNextDay: false,
endsAtLeast: false,
nextStarts: '13:00',
},
})
);
expect(mockTourDetail).toHaveBeenCalledTimes(2);
});
it('CW-08: a leg from a walk to a place keeps the search as for any leg, and reads no stops', async () => {
const user = userEvent.setup();
const onPlanTransitLeg = vi.fn();
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, withCafe: true })} />);
const connectors = await screen.findAllByLabelText('Change travel mode');
expect(connectors).toHaveLength(2);
await user.click(connectors[1]);
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
expect(onPlanTransitLeg).toHaveBeenCalledWith({
dayId: 10,
from: { name: '', lat: 52.49, lng: 13.46 },
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
time: null,
});
expect(mockTourDetail).not.toHaveBeenCalled();
});
it('CW-08: a journey saved at the walks’ ends adds no walk of nothing on either side of it', async () => {
render(<DayPlanSidebar {...walkDayProps({ withCafe: true, reservations: [savedJourney('12:50')] })} />);
// The only leg left is the one from the harbour walk to the cafe; the journey covers the rest.
await screen.findByLabelText('Change travel mode');
expect(screen.getAllByLabelText('Change travel mode')).toHaveLength(1);
expect(screen.queryByTestId('transit-walk-late')).not.toBeInTheDocument();
});
it('CW-08: a saved journey that now arrives after the next walk starts says so, and changes nothing', () => {
const onPlanTransitLeg = vi.fn();
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, reservations: [savedJourney('13:10')] })} />);
expect(screen.getByTestId('transit-walk-late')).toHaveTextContent('Arrives after Harbour starts at 13:00');
expect(onPlanTransitLeg).not.toHaveBeenCalled();
});
});
@@ -8,8 +8,9 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallba
import { avatarSrc } from '../../utils/avatarSrc'
import { safeHttpUrl } from '../../utils/safeUrl'
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
import type { PlannedTransitLeg } from './transitLeg'
import { useTransitLegPlanner } from './useTransitLegPlanner'
import { WalkLateLine } from '../Tours/walk/WalkTransitNote'
import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
import { dayViaLookup, type ViasByDay } from '../../utils/dayRouteVias'
import { routeLeg, type LegEnd } from '../Map/dayRouteLeg'
@@ -161,8 +162,9 @@ export interface DayPlanSidebarProps {
* Opens the public-transit search pre-filled for a single leg (#1281 follow-up):
* the leg's origin and destination endpoints plus the origin's departure time,
* so "public transport" becomes an option on any connector, not just the day header.
* Between two city walks it carries what the search says of them (CW-08).
*/
onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void
onPlanTransitLeg?: (leg: PlannedTransitLeg) => void
/** Opens the journey view for a saved transit entry (#1065). */
onOpenTransit?: (reservation: Reservation) => void
onEditTransport?: (reservation: Reservation) => void
@@ -789,6 +791,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
for (let i = 0; i < run.length - 1; i++) {
const from = run[i]
const to = run[i + 1]
// A booking whose station is the stop itself leaves a leg that goes nowhere, as on the phone (CW-08).
if ((!from.isPlace || !to.isPlace) && from.lat === to.lat && from.lng === to.lng) continue
const mode = resolveLegMode(from, to, dfMode)
tasks.push(async () => {
const seg = await legBetween(from, to, dayId, mode)
@@ -1405,26 +1409,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})
}
// Coordinates back to names for the transit search, over every located place,
// hotel and booking endpoint on the trip (see transitLeg.ts).
const transitNameIndex = useMemo(
() => buildTransitNameIndex(assignments, accommodations, reservations),
[assignments, accommodations, reservations],
)
// The extra connector-menu entry (#1281 follow-up): search public transit for this
// leg instead of drawing a road route. Only when a handler is wired (day has dates).
const transitLegPlanner = (dayId: number, seg?: RouteSegment) => {
if (!onPlanTransitLeg) return undefined
const leg = buildTransitLeg(seg, dayId, transitNameIndex, assignments, reservations)
if (!leg) return undefined
return () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time })
// The extra connector-menu entry (#1281 follow-up): public transit for this leg while a handler is wired (day has
// dates); between two city walks from the end of the one it leaves, `originId`, as two can end at one spot (CW-08).
const transitLegs = useTransitLegPlanner({ tripId, assignments, accommodations, reservations, places, toursEnabled,
onPlan: onPlanTransitLeg, tourOf: placeId => props.plannedTourDurations?.get(placeId)?.tour })
const transitLegPlanner = (dayId: number, seg?: RouteSegment, originId?: number) => {
const leg = transitLegs.legFor(dayId, seg)
return leg ? () => transitLegs.plan(dayId, leg, originId) : undefined
}
// Open the mode menu at the clicked connector: every route profile, the optional
// "public transport" entry, plus a "use day default" entry that clears the override.
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
ctxMenu.open(e, legModeMenu(mode => setLegMode(assignmentId, dayId, mode), transitLegPlanner(dayId, seg)))
ctxMenu.open(e, legModeMenu(m => setLegMode(assignmentId, dayId, m), transitLegPlanner(dayId, seg, assignmentId)))
}
// Set the mode of the leg ENTERING this stop (a booking arrival or the morning
@@ -2033,7 +2030,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</Tooltip>
)}
</div>
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} />
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} />
{assignment.notes && (
// Day-specific note on this stop (#2163) — one muted
// caption line so the timeline shows the note exists
@@ -2359,6 +2356,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
)
})()}
<WalkLateLine late={transitLegs.lateBefore(day, res, merged.slice(idx + 1))} />
</div>
{transitMeta && (() => {
const expanded = expandedTransitIds.has(res.id)
@@ -14,6 +14,8 @@ import { useTripStore } from '../../store/tripStore'
import { isCarrierTransport } from '../../utils/dayMerge'
import type { TransitProvider } from '@trek/shared'
import { useIsPhone } from '../../mobile/useIsPhone'
import { WalkLateLine, WalkTransitNote } from '../Tours/walk/WalkTransitNote'
import { lateForWalk, type WalkLate, type WalkTransitContext } from '../Tours/walk/walkTransit'
/**
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
@@ -314,7 +316,7 @@ function LineBadge({ leg }: { leg: TransitLeg }) {
)
}
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: {
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, late, t }: {
it: TransitItinerary
tzFrom: string
tzTo: string
@@ -323,6 +325,8 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
onToggle: () => void
onAdd: () => void
adding: boolean
/** CW-08: the next city walk this connection arrives too late for. */
late: WalkLate | null
t: (k: string, p?: Record<string, string | number>) => string
}) {
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
@@ -352,6 +356,7 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
</React.Fragment>
))}
</div>
<WalkLateLine late={late} />
</button>
{expanded && (
@@ -460,9 +465,11 @@ interface TransitSearchPanelProps {
initialTo?: PickedPlace | null
/** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */
initialTime?: string | null
/** CW-08: the two city walks the leg runs between; said only while the search is for their day. */
walk?: WalkTransitContext | null
}
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null, walk = null }: TransitSearchPanelProps) {
const { t, language } = useTranslation()
const toast = useToast()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
@@ -470,7 +477,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
const [to, setTo] = useState<PickedPlace | null>(initialTo)
const [time, setTime] = useState(initialTime || '09:00')
const walkHere = walk?.dayId === day.id ? walk : null
// Between two walks the time is when the first one ends, never one made up: unknown, it is chosen.
const [time, setTime] = useState(initialTime || (walkHere ? '' : '09:00'))
const [arriveBy, setArriveBy] = useState(false)
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
@@ -629,6 +638,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC'
const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom
const canSearch = !!from && !!to && !!time && !loading
const segBtn = (active: boolean): React.CSSProperties => ({
padding: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500,
@@ -639,6 +649,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, fontFamily: 'var(--font-system)' }}>
<WalkTransitNote walk={walkHere} />
{/* from / to — stacked tight on mobile, swap button on desktop only */}
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-end' }}>
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
@@ -707,9 +718,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
</div>
<button type="button"
onClick={search}
disabled={!from || !to || loading}
disabled={!canSearch}
className="bg-accent text-accent-text"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1, flexShrink: 0, width: isMobile ? '100%' : undefined }}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: canSearch ? 'pointer' : 'default', fontFamily: 'inherit', opacity: canSearch ? 1 : 0.55, flexShrink: 0, width: isMobile ? '100%' : undefined }}
>
<Search size={14} strokeWidth={2.2} />
{loading ? t('transit.searching') : t('transit.search')}
@@ -743,6 +754,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
onToggle={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
onAdd={() => addItinerary(it, idx)}
adding={addingIdx === idx}
late={lateForWalk(walkHere, day.date ?? null, { date: dateYMDInTz(it.endTime, tzTo), time: timeHHmmInTz(it.endTime, tzTo) })}
t={t}
/>
))}
@@ -0,0 +1,181 @@
// CW-08: the transit search between two city walks, opened from the day plan's
// connector with the first walk's end and the next one's start. The mocks are the
// ones TransitSearchPanel.test.tsx renders the panel with.
import userEvent from '@testing-library/user-event';
import { buildDay, buildPlace, buildUser } from '../../../tests/helpers/factories';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { WalkTransitContext } from '../Tours/walk/walkTransit';
import TransitSearchPanel from './TransitSearchPanel';
const { transitApiMock } = vi.hoisted(() => ({ transitApiMock: { geocode: vi.fn(), plan: vi.fn() } }));
vi.mock('../../api/client', async (importOriginal) => {
const actual = (await importOriginal()) as object;
return { ...actual, transitApi: transitApiMock };
});
vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) }));
const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' });
const OLD_BRIDGE = { name: 'Old bridge', lat: 52.51, lng: 13.42 };
const FISH_MARKET = { name: 'Fish market', lat: 52.5, lng: 13.45 };
const WALK: WalkTransitContext = {
dayId: 10,
fromWalk: 'Old town',
toWalk: 'Harbour',
ends: '12:15',
endsNextDay: false,
endsAtLeast: false,
nextStarts: '13:00',
};
// Berlin, summer time (UTC+2): a tram that departs 12:20 and arrives at `arrives` (UTC).
function itinerary(arrives: string) {
const at = (time: string) => ({ lat: 52.505, lng: 13.43, time, scheduledTime: null, track: null });
return {
startTime: '2025-06-01T10:20:00Z',
endTime: `2025-06-01T${arrives}:00Z`,
duration: 1800,
transfers: 0,
walkSeconds: 240,
legs: [
{
mode: 'TRAM',
from: { ...at('2025-06-01T10:20:00Z'), name: 'START' },
to: { ...at(`2025-06-01T${arrives}:00Z`), name: 'END' },
duration: 1800,
distance: null,
headsign: 'Harbour',
line: 'M1',
lineColor: null,
lineTextColor: null,
agency: 'BVG',
intermediateStops: 4,
},
],
};
}
function renderPanel(walk: WalkTransitContext | null, initialTime: string | null = walk?.ends ?? null) {
const onAdd = vi.fn().mockResolvedValue({});
render(
<TransitSearchPanel
day={day}
days={[day]}
places={[buildPlace({ id: 1, name: 'Old town', lat: 52.52, lng: 13.4 })]}
onAdd={onAdd}
initialFrom={OLD_BRIDGE}
initialTo={FISH_MARKET}
initialTime={initialTime}
walk={walk}
/>
);
return { onAdd };
}
const timeField = () => screen.getByPlaceholderText('00:00') as HTMLInputElement;
const searchButton = () => screen.getByRole('button', { name: /^Search$/ });
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as never);
});
describe('TransitSearchPanel between two city walks (CW-08)', () => {
it('says when the first walk ends and the next starts, and departs from the last stop then', async () => {
const user = userEvent.setup();
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
renderPanel(WALK);
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
const [from, to] = screen.getAllByPlaceholderText('Search stop or station…') as HTMLInputElement[];
expect(from?.value).toBe('Old bridge');
expect(to?.value).toBe('Fish market');
expect(timeField().value).toBe('12:15');
await user.click(searchButton());
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
// 12:15 in Berlin (UTC+2), from the walk's end and to the next one's start.
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({
from: '52.51,13.42',
to: '52.5,13.45',
time: '2025-06-01T10:15:00.000Z',
});
});
it('marks a connection that arrives after the next walk starts, and only that one', async () => {
const user = userEvent.setup();
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('11:10'), itinerary('10:50')] });
renderPanel(WALK);
await user.click(searchButton());
await screen.findByText(/12:20\D+13:10/);
const late = screen.getAllByTestId('transit-walk-late');
expect(late).toHaveLength(1);
expect(late[0]).toHaveTextContent('Arrives after Harbour starts at 13:00');
expect(screen.getByText(/12:20\D+13:10/).closest('button')).toContainElement(late[0] ?? null);
});
it('says an end that leaves a time out is the earliest', () => {
renderPanel({ ...WALK, endsAtLeast: true });
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
'Old town ends at 12:15 at the earliest; not all of its times are known yet.'
);
});
it('says a walk that ends after midnight does so, and makes up no time on this day', () => {
renderPanel({ ...WALK, ends: null, endsNextDay: true, endsAtLeast: true });
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
'Old town only ends after midnight, so choose a departure time.'
);
expect(timeField().value).toBe('');
});
it('makes up no time without an end, and waits for one to be chosen', async () => {
const user = userEvent.setup();
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
renderPanel({ ...WALK, ends: null, nextStarts: null });
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
'When Old town ends is not known yet, so choose a departure time.'
);
expect(screen.queryByTestId('transit-walk-next')).not.toBeInTheDocument();
expect(timeField().value).toBe('');
expect(searchButton()).toBeDisabled();
await user.type(timeField(), '1230');
await user.tab();
expect(searchButton()).toBeEnabled();
await user.click(searchButton());
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({ time: '2025-06-01T10:30:00.000Z' });
});
it('on another day than the walks, speaks of them no more and departs at the usual time', () => {
renderPanel({ ...WALK, dayId: 11 }, null);
expect(screen.queryByTestId('transit-walk-end')).not.toBeInTheDocument();
expect(timeField().value).toBe('09:00');
});
it('adds a connection only once it is opened and taken, between the two stops', async () => {
const user = userEvent.setup();
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('10:50')] });
const { onAdd } = renderPanel(WALK);
await user.click(searchButton());
await user.click(await screen.findByText(/12:20\D+12:50/));
expect(onAdd).not.toHaveBeenCalled();
await user.click(await screen.findByRole('button', { name: 'Add to day' }));
await waitFor(() => expect(onAdd).toHaveBeenCalledTimes(1));
const payload = onAdd.mock.calls[0]?.[0];
expect(payload).toMatchObject({ type: 'transit', title: 'Old bridge → Fish market', day_id: 10 });
expect(payload.endpoints.map((point: { name: string }) => point.name)).toEqual(['Old bridge', 'Fish market']);
});
});
@@ -519,6 +519,15 @@ describe('TransportModal', () => {
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
});
it('FE-PLANNER-TRANSMODAL-023b: a leg between two city walks opens the search with what it says of them (CW-08)', () => {
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as Day[];
const walk = { dayId: 10, fromWalk: 'Old town', toWalk: 'Harbour', ends: '12:15', endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' };
const transitPrefill = { from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, to: { name: 'Fish market', lat: 52.5, lng: 13.45 }, time: '12:15', walk };
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated transitPrefill={transitPrefill} places={[]} accommodations={[]} />);
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
});
it('FE-PLANNER-TRANSMODAL-028: automated quick picks only offer the chosen day\'s places (#1460)', async () => {
const days = [
buildDay({ id: 10, date: '2025-06-01' }),
@@ -19,7 +19,8 @@ import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
import TransitSearchPanel from './TransitSearchPanel'
import type { TransitPrefill } from './transitLeg'
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton } from '../shared/DialogShell'
import { INPUT, READONLY_BOX, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts'
@@ -70,7 +71,7 @@ interface TransportModalProps {
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
tripHasDates?: boolean
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
transitPrefill?: TransitPrefill | null
/** Trip members + guests, for the traveler picker (#1517). */
tripMembers?: TripMember[]
}
@@ -304,6 +305,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
initialFrom={transitPrefill?.from ?? null}
initialTo={transitPrefill?.to ?? null}
initialTime={transitPrefill?.time ?? null}
walk={transitPrefill?.walk ?? null}
/>
)
}
@@ -83,14 +83,12 @@ export function stayFormFrom<P>(
}
/**
* The places a stay can be at (no GPX tracks, but always the one already picked),
* The places a stay can be at (no GPX tracks or tours, but always the one already picked),
* narrowed to a category when one is chosen.
*/
export function stayPlaceChoices<P extends { id: number; route_geometry?: string | null; category_id?: number | null }>(
places: P[],
placeId: number | string | null | undefined,
categoryId: number | null
): P[] {
export function stayPlaceChoices<
P extends { id: number; route_geometry?: string | null; tour_place_id?: number | null; category_id?: number | null },
>(places: P[], placeId: number | string | null | undefined, categoryId: number | null): P[] {
const offered = stayPlaces<P>(places, placeId);
return categoryId != null ? offered.filter((p) => p.category_id === categoryId) : offered;
}
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-006
// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-007
const visit = (id: number, dayId: number, name: string, lat: number, lng: number, placeTime?: string) =>
({ id, day_id: dayId, place: { id, name, lat, lng, place_time: placeTime ?? null } }) as unknown as Assignment
@@ -69,4 +69,11 @@ describe('buildTransitLeg', () => {
expect(buildTransitLeg(undefined, 1, names, assignments, [])).toBeNull()
expect(buildTransitLeg({ from: [1, 1] } as unknown as RouteSegment, 1, names, assignments, [])).toBeNull()
})
it('FE-PLANNER-TRANSITLEG-007: a leg leaving a loop tour does not depart when the tour starts (CW-08)', () => {
// A tour's place sits at its start; a loop ends there too, but its time is when it starts.
const loop = { ...visit(31, 3, 'Old town loop', 52.52, 13.4, '10:00'), tour_place_id: 31 } as Assignment
const tours = { '3': [loop, visit(32, 3, 'Harbour', 52.5, 13.45)] }
expect(buildTransitLeg(seg([52.52, 13.4], [52.5, 13.45]), 3, buildTransitNameIndex(tours, [], []), tours, [])?.time).toBeNull()
})
})
+18 -1
View File
@@ -1,4 +1,5 @@
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
import type { WalkTransitContext } from '../Tours/walk/walkTransit'
import type { PickedPlace } from './TransitSearchPanel'
/** What the transit search is opened with for one leg of a day. */
@@ -8,6 +9,20 @@ export interface TransitLeg {
time: string | null
}
/** A leg as a view hands it on to open the search: its day and, between two city walks, what it says of them (CW-08). */
export interface PlannedTransitLeg extends TransitLeg {
dayId: number
walk?: WalkTransitContext
}
/** What the search is seeded with: a leg, or the ends of a journey whose route is changed. */
export interface TransitPrefill {
from?: PickedPlace | null
to?: PickedPlace | null
time?: string | null
walk?: WalkTransitContext | null
}
/**
* A connector's endpoints carry only coordinates (RouteSegment.from/to, both
* [lat, lng]); resolve them back to the NAMES the transit search shows by indexing
@@ -48,7 +63,9 @@ function originDepartureTime(
assignments: Record<string, Assignment[]>,
reservations: Reservation[],
): string | null {
for (const a of assignments[String(dayId)] ?? []) {
// A tour's place is where it starts and its time when it starts: a leg leaving a loop is no departure then.
const stops = (assignments[String(dayId)] ?? []).filter(a => a.tour_place_id == null || a.tour_place_id !== a.place?.id)
for (const a of stops) {
if (a.place?.lat != null && a.place?.lng != null && `${a.place.lat},${a.place.lng}` === originKey) return a.place.place_time ?? null
}
for (const r of reservations) {
@@ -0,0 +1,255 @@
import { renderHook, waitFor } from '@testing-library/react';
import type { TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { tourRepo } from '../../repo/tourRepo';
import type { RouteSegment } from '../../types';
import type { MergedItem } from '../../utils/dayMerge';
import { useTransitLegPlanner, type TransitLegPlannerDeps } from './useTransitLegPlanner';
// CW-08: the transit leg behind the desktop day plan and the phone timeline.
vi.mock('../../repo/tourRepo', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
return { ...actual, tourRepo: { ...actual.tourRepo, detail: vi.fn() } };
});
const oldTown = buildPlace({
id: 51,
name: 'Old town',
lat: 52.52,
lng: 13.4,
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
});
const harbour = buildPlace({
id: 52,
name: 'Harbour',
lat: 52.5,
lng: 13.45,
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
});
const museum = buildPlace({ id: 53, name: 'Museum', lat: 52.47, lng: 13.48, place_time: '9:05' });
const cafe = buildPlace({ id: 54, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
const walkA = buildAssignment({
id: 201,
day_id: 10,
order_index: 0,
tour_place_id: 51,
place: { ...oldTown, place_time: '10:00' },
});
const walkB = buildAssignment({
id: 202,
day_id: 10,
order_index: 1,
tour_place_id: 52,
place: { ...harbour, place_time: '13:00' },
});
const assignments = {
'10': [walkB, walkA],
'11': [
buildAssignment({ id: 301, day_id: 11, order_index: 0, place: museum }),
buildAssignment({ id: 302, day_id: 11, order_index: 1, place: cafe }),
],
};
function walk(place: { id: number; name: string }): TourListItem {
return {
place_id: place.id,
name: place.name,
tour_type: 'walk',
duration: 90,
planned_duration_minutes: null,
break_additional_minutes: null,
station_count: 2,
station_stay_minutes: 45,
station_stays_unknown: 0,
} as TourListItem;
}
const tours = new Map([walk(oldTown), walk(harbour)].map((tour) => [tour.place_id, tour]));
const BETWEEN_WALKS = { from: [52.51, 13.42], to: [52.5, 13.45] } as unknown as RouteSegment;
const MUSEUM_TO_CAFE = { from: [52.47, 13.48], to: [52.48, 13.47] } as unknown as RouteSegment;
function setup(overrides: Partial<TransitLegPlannerDeps> = {}) {
const onPlan = vi.fn();
const deps: TransitLegPlannerDeps = {
tripId: 1,
assignments,
accommodations: [],
reservations: [],
places: [oldTown, harbour, museum, cafe],
toursEnabled: true,
tourOf: (placeId) => tours.get(placeId),
onPlan,
...overrides,
};
const { result, unmount } = renderHook(() => useTransitLegPlanner(deps), { wrapper: TranslationProvider });
return { onPlan, result, unmount };
}
const detail = (placeId: number, names: [string, string]) => ({
tour: tours.get(placeId),
waypoints: names.map((name, sequence) => ({
lat: 0,
lng: 0,
role: 'via',
sequence,
key: `${placeId}-${sequence}`,
name,
})),
});
/** Walk reads that answer only once let go, and what each was asked with. */
function heldReads() {
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
gate.then(() => detail(placeId, ['First', 'Last']) as never)
);
const signals = () => vi.mocked(tourRepo.detail).mock.calls.map((call) => call[2]?.aborted);
// A turn of the event loop lets every read and what follows it settle.
const settle = async () => {
release();
await new Promise((resolve) => setTimeout(resolve, 0));
};
return { signals, settle };
}
beforeEach(() => {
vi.mocked(tourRepo.detail).mockReset();
});
describe('useTransitLegPlanner', () => {
it('offers no leg without a way to open the search, and the leg as for any connector with one', () => {
expect(setup({ onPlan: undefined }).result.current.legFor(11, MUSEUM_TO_CAFE)).toBeNull();
expect(setup().result.current.legFor(11, MUSEUM_TO_CAFE)).toEqual({
from: { name: 'Museum', lat: 52.47, lng: 13.48 },
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
time: '09:05',
});
});
it('opens the search for an ordinary leg at once, with nothing said of walks', () => {
const { onPlan, result } = setup();
const leg = result.current.legFor(11, MUSEUM_TO_CAFE);
if (!leg) throw new Error('no leg');
result.current.plan(11, leg, 301);
expect(onPlan.mock.calls[0]?.[0]).toStrictEqual({ dayId: 11, ...leg });
expect(tourRepo.detail).not.toHaveBeenCalled();
});
it('between two walks, opens it from the last stop to the first once both are read, when the first one ends', async () => {
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
Promise.resolve(
detail(placeId, placeId === 51 ? ['Town hall', 'Old bridge'] : ['Fish market', 'Lighthouse']) as never
)
);
const { onPlan, result } = setup();
const leg = result.current.legFor(10, BETWEEN_WALKS);
if (!leg) throw new Error('no leg');
result.current.plan(10, leg, walkA.id);
expect(onPlan).not.toHaveBeenCalled();
await waitFor(() =>
expect(onPlan).toHaveBeenCalledWith({
dayId: 10,
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
time: '12:15',
walk: {
dayId: 10,
fromWalk: 'Old town',
toWalk: 'Harbour',
ends: '12:15',
endsNextDay: false,
endsAtLeast: false,
nextStarts: '13:00',
},
})
);
expect(vi.mocked(tourRepo.detail).mock.calls.map((call) => call[1])).toEqual([51, 52]);
});
it('still opens it where a walk cannot be read, naming that end after its walk', async () => {
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
placeId === 51
? Promise.reject(new Error('Tour not available offline'))
: // Route ends without keys stand in for stops this device does not hold.
Promise.resolve({
tour: tours.get(52),
waypoints: [{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0 }],
} as never)
);
const { onPlan, result } = setup();
const leg = result.current.legFor(10, BETWEEN_WALKS);
if (!leg) throw new Error('no leg');
result.current.plan(10, leg, walkA.id);
await waitFor(() => expect(onPlan).toHaveBeenCalledTimes(1));
expect(onPlan.mock.calls[0]?.[0]).toMatchObject({
from: { name: 'End of Old town' },
to: { name: 'Start of Harbour' },
time: '12:15',
});
});
it('with tours off, treats the leg between two walks as any other', () => {
const { onPlan, result } = setup({ toursEnabled: false });
const leg = result.current.legFor(10, BETWEEN_WALKS);
if (!leg) throw new Error('no leg');
result.current.plan(10, leg, walkA.id);
expect(onPlan).toHaveBeenCalledWith({ dayId: 10, ...leg });
expect(tourRepo.detail).not.toHaveBeenCalled();
});
it('opens nothing without a way to open the search', () => {
const { result } = setup({ onPlan: undefined });
const leg = { from: { name: '', lat: 52.51, lng: 13.42 }, to: { name: '', lat: 52.5, lng: 13.45 }, time: null };
expect(() => result.current.plan(10, leg, walkA.id)).not.toThrow();
expect(tourRepo.detail).not.toHaveBeenCalled();
});
it('opens only the leg asked for last, and drops the walks still being read for an earlier one', async () => {
const reads = heldReads();
const { onPlan, result } = setup();
const between = result.current.legFor(10, BETWEEN_WALKS);
const ordinary = result.current.legFor(11, MUSEUM_TO_CAFE);
if (!between || !ordinary) throw new Error('no leg');
result.current.plan(10, between, walkA.id);
result.current.plan(10, between, walkA.id);
result.current.plan(11, ordinary, 301);
expect(reads.signals()).toEqual([true, true, true, true]);
await reads.settle();
expect(onPlan).toHaveBeenCalledTimes(1);
expect(onPlan).toHaveBeenCalledWith({ dayId: 11, ...ordinary });
});
it('opens nothing once the view is gone before the walks are read', async () => {
const reads = heldReads();
const { onPlan, result, unmount } = setup();
const leg = result.current.legFor(10, BETWEEN_WALKS);
if (!leg) throw new Error('no leg');
result.current.plan(10, leg, walkA.id);
expect(reads.signals()).toEqual([false, false]);
unmount();
expect(reads.signals()).toEqual([true, true]);
await reads.settle();
expect(onPlan).not.toHaveBeenCalled();
});
it('says when a saved journey now arrives after the walk it leads to starts, while tours are on', () => {
const journey = buildReservation({ id: 300, type: 'transit', reservation_end_time: '2025-06-01T13:10' });
const following = [{ type: 'place', sortKey: 1, data: walkB }] as MergedItem[];
const day = { date: '2025-06-01' };
expect(setup().result.current.lateBefore(day, journey, following)).toEqual({ walk: 'Harbour', time: '13:00' });
expect(setup({ toursEnabled: false }).result.current.lateBefore(day, journey, following)).toBeNull();
});
});
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from '../../i18n';
import type { Accommodation, AssignmentsMap, Day, Place, Reservation, RouteSegment } from '../../types';
import type { MergedItem } from '../../utils/dayMerge';
import { projectDayItinerary } from '../Map/dayTourProjection';
import { readWalkStations } from '../Tours/walk/walkStationsRead';
import { journeyLate, walkPairAt, walkTransitLeg, type TourOf, type WalkLate } from '../Tours/walk/walkTransit';
import { buildTransitLeg, buildTransitNameIndex, type PlannedTransitLeg, type TransitLeg } from './transitLeg';
export interface TransitLegPlannerDeps {
tripId: number | string;
assignments: AssignmentsMap;
accommodations: Accommodation[];
reservations: Reservation[];
places: Place[];
toursEnabled: boolean;
tourOf: TourOf;
/** Opens the search for a leg; without it no leg offers one, as on a trip without dates. */
onPlan: ((leg: PlannedTransitLeg) => void) | undefined;
}
/** A walk's stops, or null where this device cannot read them. */
async function stopsOrNull(tripId: number | string, placeId: number, signal: AbortSignal) {
try {
return await readWalkStations(tripId, placeId, signal);
} catch {
// They only name the leg's two ends: without them it opens named after the walks.
return null;
}
}
/**
* Public transit for one leg of a day (#2398), behind the desktop day plan and
* the phone timeline alike: the leg's two ends and the time its origin is
* planned for. Between two city walks (CW-08) it runs from the first walk's
* last stop to the next one's first, departing when the first one ends, and a
* saved journey that now arrives after the next walk starts says so. Nothing
* is saved here: the search opens, and only the connection picked there is
* added to the day. Only the last leg asked for opens it, and none after the
* view is gone.
*/
export function useTransitLegPlanner({
tripId,
assignments,
accommodations,
reservations,
places,
toursEnabled,
tourOf,
onPlan,
}: TransitLegPlannerDeps) {
const { t } = useTranslation();
// Coordinates back to names, over every located place, hotel and booking endpoint of the trip.
const names = useMemo(
() => buildTransitNameIndex(assignments, accommodations, reservations),
[assignments, accommodations, reservations]
);
const legFor = useCallback(
(dayId: number, seg?: RouteSegment): TransitLeg | null =>
onPlan ? buildTransitLeg(seg, dayId, names, assignments, reservations) : null,
[onPlan, names, assignments, reservations]
);
// The walks' stops being read for the leg asked for last; a later leg, or leaving the view, drops them.
const pending = useRef<AbortController | null>(null);
useEffect(() => () => pending.current?.abort(), []);
/** Opens the search for a leg, told by the entry it leaves where the connector carries one. */
const plan = useCallback(
(dayId: number, leg: TransitLeg, originId?: number) => {
if (!onPlan) return;
pending.current?.abort();
const day = [...(assignments[String(dayId)] ?? [])].sort((a, b) => a.order_index - b.order_index);
const pair = toursEnabled ? walkPairAt(projectDayItinerary(day, true, places), leg, tourOf, originId) : null;
if (!pair) {
onPlan({ dayId, ...leg });
return;
}
const controller = new AbortController();
pending.current = controller;
// The stops name the ends; read from this device's copy when offline, so the search waits only for them.
void Promise.all([
stopsOrNull(tripId, pair.first.item.placeId, controller.signal),
stopsOrNull(tripId, pair.next.item.placeId, controller.signal),
]).then(([first, next]) => {
if (controller.signal.aborted) return;
onPlan({ dayId, ...walkTransitLeg({ leg, dayId, pair, stops: { first, next }, places, t }) });
});
},
[onPlan, assignments, toursEnabled, places, tourOf, tripId, t]
);
/** A saved journey on a day that arrives after the walk it leads to starts; null otherwise. */
const lateBefore = useCallback(
(day: Pick<Day, 'date'>, reservation: Reservation, following: readonly MergedItem[]): WalkLate | null =>
toursEnabled ? journeyLate({ reservation, following, date: day.date ?? null, tourOf }) : null,
[toursEnabled, tourOf]
);
return { legFor, plan, lateBefore };
}
@@ -1,8 +1,9 @@
import type { TourListItem } from '@trek/shared';
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { paddleBench } from '../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useTourVisitStore } from '../../store/tourVisitStore';
import TourDayRowFacts, { TourDayRowIcon, TourDayRowPills } from './TourDayRowFacts';
import { tourDayRowIcon } from './tourDayRowIcon';
@@ -117,6 +118,78 @@ describe('PAD-09 the check bench on a tour row', () => {
});
});
describe("CW-02 a city walk's time on its day row", () => {
const walk: TourListItem = {
...tour,
tour_type: 'walk',
duration: 45,
break_additional_minutes: 15,
max_hiking_difficulty: null,
station_count: 3,
station_stay_minutes: 120,
station_stays_unknown: 0,
};
it('shows the walking time and the stays as the planned total, its breaks left out', () => {
render(<TourDayRowPills tour={walk} />);
const time = screen.getByTestId('trip-plan-tour-time');
expect(time).toHaveTextContent('2 h 45 min');
expect(time).toHaveAccessibleName('Walking: 45 min, Stays: 2 h, Planned total duration: 2 h 45 min');
});
it('says the total is at least what is known while a stay is not entered', () => {
render(<TourDayRowPills tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} />);
const time = screen.getByTestId('trip-plan-tour-time');
expect(time).toHaveTextContent('≥ 2 h 15 min');
expect(time).toHaveAccessibleName(
'Walking: 45 min, Stays: at least 1 h 30 min, Planned total duration: at least 2 h 15 min'
);
});
it('shows the same total among the facts of a phone row, and no breaks', () => {
render(<TourDayRowFacts tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} mobile />);
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('≥ 2 h 15 min');
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking: 45 min');
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
});
});
describe("CW-03 a city walk's progress among the facts of a day row", () => {
const walk: TourListItem = { ...tour, tour_type: 'walk', max_hiking_difficulty: null, station_count: 5 };
beforeEach(() =>
useTourVisitStore.setState({
tripId: '4',
byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'skipped' } },
})
);
it.each([false, true])('shows the stops visited on that day, a tap naming them whole (mobile=%s)', (mobile) => {
const openRow = vi.fn();
render(
<div onClick={openRow}>
<TourDayRowFacts tour={walk} assignmentId={9} mobile={mobile} />
</div>
);
const progress = screen.getByTestId('plan-walk-progress');
expect(progress).toHaveTextContent('1/5');
expect(progress).toHaveAccessibleName('1 of 5 visited');
fireEvent.click(progress);
expect(screen.getByRole('note')).toHaveTextContent('1 of 5 visited');
expect(openRow).not.toHaveBeenCalled();
});
it('shows none without a day, or before a stop of that day is marked', () => {
render(
<>
<TourDayRowFacts tour={walk} mobile />
<TourDayRowFacts tour={walk} assignmentId={10} mobile />
</>
);
expect(screen.queryByTestId('plan-walk-progress')).not.toBeInTheDocument();
});
});
describe('the compact facts of a tour list row', () => {
const testIds = () =>
[...document.querySelectorAll('[data-testid^="plan-tour"], [data-testid="trip-plan-tour-time"]')].map((el) =>
+38 -19
View File
@@ -1,5 +1,5 @@
import type { TourListItem } from '@trek/shared';
import { ArrowUp, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { ArrowUp, CheckCheck, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { createElement, useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
@@ -10,7 +10,9 @@ import { Tooltip } from '../shared/Tooltip';
import { paddleCheckFact } from './paddle/paddleCheckModel';
import { tourDayRowIcon } from './tourDayRowIcon';
import { movingTimeLabel } from './tourFacts';
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation';
import { useWalkProgress } from './walk/walkProgress';
import { walkStaysText } from './walk/walkTimeFields';
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
const { t } = useTranslation();
@@ -163,27 +165,24 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
const times = tourPlannedTimes(tour);
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes;
const walking = times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes);
const total = plannedTotalText(times, t);
const staysText = times.walk ? walkStaysText(times.walk, t) : null;
const timeParts = [
times.walkingMinutes != null
? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}`
: null,
walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null,
times.breakMinutes
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
: null,
times.plannedTotalMinutes != null
? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
// CW-02: a city walk's stays, which its total adds to the walking time.
staysText ? `${t('tours.walk.stays')}: ${staysText}` : null,
total
? `${t('tours.planner.plannedTotalDuration')}: ${total}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
: null,
].filter((part): part is string => part !== null);
const check = paddleCheckOf(tour, t);
const time = shownMinutes != null && (
<TourDayRowPill
key="time"
icon={Clock}
value={formatPlannedTourDuration(shownMinutes)}
label={timeParts.join(', ')}
testId="trip-plan-tour-time"
/>
const shown = plannedTotalText(times, t, true) ?? walking;
const time = shown != null && (
<TourDayRowPill key="time" icon={Clock} value={shown} label={timeParts.join(', ')} testId="trip-plan-tour-time" />
);
const distance = tour.distance != null && (
<TourDayRowPill
@@ -209,9 +208,19 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
return <>{tour.tour_type === 'kayak' ? [time, bench, distance] : [time, distance, ascent]}</>;
}
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
export default function TourDayRowFacts({
tour,
assignmentId,
mobile = false,
}: {
tour: TourListItem;
/** The row's day assignment, for a city walk's progress on that day (CW-03). */
assignmentId?: number;
mobile?: boolean;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
const progress = useWalkProgress(tour, assignmentId);
const times = tourPlannedTimes(tour);
const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string }> = [];
const add = (icon: LucideIcon, value: string, label: string, testId: string) =>
@@ -234,10 +243,11 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
t('tours.planner.breaksAdditional'),
'trip-plan-tour-breaks'
);
if (times.plannedTotalMinutes != null)
const total = plannedTotalText(times, t, true);
if (total)
add(
Clock,
formatPlannedTourDuration(times.plannedTotalMinutes),
total,
`${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
'trip-plan-tour-planned-duration'
);
@@ -248,6 +258,15 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
{facts.map((fact) => (
<Fact key={fact.testId} {...fact} label={`${fact.label}: ${fact.value}`} mobile={mobile} />
))}
{progress && (
<Fact
icon={CheckCheck}
value={`${progress.visited}/${progress.total}`}
label={t('tours.walk.progress', progress)}
testId="plan-walk-progress"
mobile={mobile}
/>
)}
</span>
);
}
@@ -1,13 +1,14 @@
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { buildPlace } from '../../../tests/helpers/factories'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import { paddleBench } from '../../../tests/helpers/paddleCheck'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import TourDetailDialog from './TourDetailDialog'
import { useTripStore } from '../../store/tripStore'
import { tourRepo } from '../../repo/tourRepo'
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
@@ -60,6 +61,11 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
})
// A repository answer one test stubs never reaches the tests after it.
afterEach(() => {
vi.restoreAllMocks()
})
describe('TourDetailDialog', () => {
it('keeps desktop detail non-modal and returns focus to its Tour row on Escape', async () => {
const opener = document.createElement('button')
@@ -142,6 +148,85 @@ describe('TourDetailDialog', () => {
expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument()
})
it('CW-02: shows a city walk its walking time, its stays and their total, and no breaks left from another type', async () => {
const walk = {
...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, duration: 45, break_additional_minutes: 15,
station_count: 3, station_stay_minutes: 120, station_stays_unknown: 0,
}
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: walk, waypoints: [] })
const view = render(<TourDetailDialog {...defaultProps} tour={walk} />)
// 45 min of walking, a museum of 90 min and a cafe of 30 min, as the day row says it too.
expect(screen.getByLabelText('Walking: 45 min, Stays: 2 h, Planned: 2 h 45 min')).toHaveTextContent('2 h 45 min')
expect(screen.getByText('Stays')).toBeInTheDocument()
expect(screen.queryByText('Breaks')).not.toBeInTheDocument()
view.rerender(<TourDetailDialog {...defaultProps} tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} />)
expect(screen.getByLabelText('Walking: 45 min, Stays: at least 1 h 30 min, Planned: at least 2 h 15 min'))
.toHaveTextContent('≥ 2 h 15 min')
// On a phone's three narrow columns the longer values wrap rather than being cut.
const stays = screen.getByText('Stays').closest('dt')?.nextElementSibling
expect(stays).toHaveTextContent('at least 1 h 30 min')
expect(stays).toHaveClass('break-words')
expect(stays).not.toHaveClass('truncate')
view.rerender(<TourDetailDialog {...defaultProps}
tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1, planned_duration_minutes: 240 }} />)
expect(screen.getByLabelText(
'Walking: 45 min, Stays: at least 1 h 30 min, Planned (Manual override): 4 h, Computed: at least 2 h 15 min'
)).toHaveTextContent('4 h')
expect(screen.getByText('Computed')).toBeInTheDocument()
expect(await screen.findByTestId('walk-stations')).toBeInTheDocument()
})
it('CW-04: a walk opened from a day it is on keeps that day for its times; opened otherwise, a day can be picked', async () => {
const walk = { ...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, duration: 20, station_count: 1 }
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: walk, waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0, key: 'stop-01', name: 'Museum', stay_minutes: 30, opening_hours: 'Mo-Fr 10:00-18:00' },
] })
// 2026-10-19 is a Monday: the museum opens at 10:00.
const days = [buildDay({ id: 7, date: '2026-10-19' }), buildDay({ id: 8, date: '2026-10-20' })]
const assignments = {
'7': [buildAssignment({ id: 9, day_id: 7, place_id: 42, assignment_time: '09:00', place })],
'8': [buildAssignment({ id: 10, day_id: 8, place_id: 42, assignment_time: '11:00', place })],
}
const view = render(<TourDetailDialog {...defaultProps} tour={walk} days={days} assignments={assignments} selectedDayId={7} />)
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, Mon, Oct 19(, 2026)?, start 09:00$/)
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument()
expect(await screen.findByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument()
expect(screen.getByTestId('walk-station-conflict'))
.toHaveTextContent('Museum is closed at 09:00, the stay would last until 09:30; open 10:00 to 18:00')
expect(screen.getByText('Mark visited')).toBeInTheDocument()
view.rerender(<TourDetailDialog {...defaultProps} tour={walk} days={days} assignments={assignments} />)
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent(/^Day 1, Mon, Oct 19(, 2026)?, start 09:00$/)
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('walk-day-picker'))
fireEvent.click(screen.getByRole('button', { name: /^Day 2, .*start 11:00$/ }))
expect(await screen.findByText('Arrival about 11:00, stay until 11:30')).toBeInTheDocument()
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument()
expect(screen.getByText(/Open 10:00 to 18:00/)).toBeInTheDocument()
})
it('CW-11: a walk saved with its stops only says once that its way is planned in the planner, and shows no measures yet', async () => {
const pending = {
...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, distance: null, elevation_gain: null, elevation_loss: null,
station_count: 2, route_pending: true,
}
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: pending, waypoints: [] })
const view = render(<TourDetailDialog {...defaultProps} tour={pending} place={{ ...place, route_geometry: null }} />)
expect(screen.getByText(
'Saved with its stops only. The way between them is planned when the walk is opened in the planner and saved.'
)).toBeInTheDocument()
expect(screen.queryByText('Distance')).not.toBeInTheDocument()
expect(await screen.findByTestId('walk-stations')).toBeInTheDocument()
view.rerender(<TourDetailDialog {...defaultProps} tour={{ ...pending, route_pending: false }} />)
expect(screen.queryByText(/Saved with its stops only/)).not.toBeInTheDocument()
})
it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', has_waypoints: true, duration: 50 }} />)
+117 -72
View File
@@ -58,12 +58,17 @@ import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField }
import {
formatPlannedTourDuration,
hikeSourceBadgeLabel,
plannedTotalText,
tourPlannedTimes,
tourSource,
tourWebsitePresentation,
type TourPlannedTimes,
} from './tourPresentation';
import { useTourDayAssignment } from './useTourDayAssignment';
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions';
import { WalkRoutePendingHint } from './walk/WalkRoutePending';
import { WalkStationsCard } from './walk/WalkStations';
import { walkTimeFields } from './walk/walkTimeFields';
interface TourDetailDialogProps extends TourPermissionProps {
tour: TourListItem;
@@ -166,19 +171,28 @@ function placeStart(place: Place): { lat: number; lng: number } | null {
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */
/**
* The parts of the time a tour takes, as fields: walking, breaks, the planned
* total; a city walk its walking time, its stays and their total, in the
* planner's wording (CW-02).
*/
function tourTimeFields(
times: ReturnType<typeof tourPlannedTimes>,
times: TourPlannedTimes,
t: Translate,
tour: Pick<TourListItem, 'tour_type'>
): TourMetricField[] {
const walking = { label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t) };
if (times.walk) {
return walkTimeFields({
walking,
walkingMinutes: times.walkingMinutes,
stays: times.walk,
manualMinutes: times.manuallyOverridden ? times.plannedTotalMinutes : null,
t,
});
}
return [
times.walkingMinutes != null
? {
label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t),
value: formatPlannedTourDuration(times.walkingMinutes),
}
: null,
times.walkingMinutes != null ? { ...walking, value: formatPlannedTourDuration(times.walkingMinutes) } : null,
times.breakMinutes
? { label: t('tours.planner.breaksShort'), value: formatPlannedTourDuration(times.breakMinutes) }
: null,
@@ -201,19 +215,23 @@ function TourHeadPills({
tour,
trackColor,
timeFields,
shownMinutes,
times,
metrics,
}: {
tour: TourListItem;
trackColor: string;
timeFields: TourMetricField[];
shownMinutes: number | null;
/** The planned total where there is one, as the day row shows it, otherwise the walking time. */
times: TourPlannedTimes;
/** Measured from the line as it is now, so the head says what the fields below say. */
metrics: { distanceKm: number | null; gain: number | null; loss: number | null };
}) {
const { t } = useTranslation();
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const timeTooltip = timeFields.map((field) => `${field.label}: ${field.value}`).join(', ');
const shownTime =
plannedTotalText(times, t, true) ??
(times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes));
const source = tourSource(tour);
// The import this tour was planned from, named while it is still in the trip.
const derivedFromName = useTripStore((state) =>
@@ -257,11 +275,11 @@ function TourHeadPills({
</span>
</Tooltip>
)}
{shownMinutes != null && (
{shownTime != null && (
<Tooltip label={timeTooltip}>
<span aria-label={timeTooltip} className={HEAD_PILL} style={fs(10.5)}>
<Clock size={11} strokeWidth={2} className="flex-none text-content-faint" />
{formatPlannedTourDuration(shownMinutes)}
{shownTime}
</span>
</Tooltip>
)}
@@ -369,6 +387,79 @@ function useTourNameEditor(place: Place, onUpdatePlace?: TourDetailDialogProps['
return { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName };
}
/** The tour's name in the head; `onUpdatePlace` is given only to someone who may rename it, in place. */
function TourNameHeading({
place,
onUpdatePlace,
}: {
place: Place;
onUpdatePlace?: TourDetailDialogProps['onUpdatePlace'];
}) {
const { t } = useTranslation();
const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor(
place,
onUpdatePlace
);
if (onUpdatePlace && editingName) {
return (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void saveName();
if (event.key === 'Escape') {
event.preventDefault();
setNameDraft(place.name);
setEditingName(false);
}
}}
aria-label={t('places.formName')}
className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
style={fs(17, 'subtitle')}
/>
<Tooltip label={t('common.save')}>
<button
type="button"
onClick={() => void saveName()}
disabled={savingName}
aria-label={t('common.save')}
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-accent text-accent-text disabled:opacity-50"
>
<Check size={14} strokeWidth={2.4} />
</button>
</Tooltip>
</div>
);
}
return (
<div className="flex min-w-0 items-center gap-1.5">
<h2
className="m-0 min-w-0 break-words font-bold leading-snug tracking-[-0.01em] text-content"
style={fs(17, 'subtitle')}
>
{place.name}
</h2>
{onUpdatePlace && (
<Tooltip label={t('common.edit')}>
<button
type="button"
onClick={() => {
setNameDraft(place.name);
setEditingName(true);
}}
aria-label={t('common.edit')}
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"
>
<Pencil size={12} strokeWidth={2.2} />
</button>
</Tooltip>
)}
</div>
);
}
export default function TourDetailDialog({
tour,
place,
@@ -405,10 +496,6 @@ export default function TourDetailDialog({
const [colorOpen, setColorOpen] = useState(false);
const [navigationOpen, setNavigationOpen] = useState(false);
const navigationAnchorRef = useRef<HTMLSpanElement>(null);
const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor(
place,
canEdit ? onUpdatePlace : undefined
);
useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget);
const heightsHidden = heightsUnknown(tour);
@@ -453,66 +540,12 @@ export default function TourDetailDialog({
</Tooltip>
</div>
<div className="min-w-0 flex-1 pt-0.5">
{canEdit && editingName ? (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void saveName();
if (event.key === 'Escape') {
event.preventDefault();
setNameDraft(place.name);
setEditingName(false);
}
}}
aria-label={t('places.formName')}
className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
style={fs(17, 'subtitle')}
/>
<Tooltip label={t('common.save')}>
<button
type="button"
onClick={() => void saveName()}
disabled={savingName}
aria-label={t('common.save')}
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-accent text-accent-text disabled:opacity-50"
>
<Check size={14} strokeWidth={2.4} />
</button>
</Tooltip>
</div>
) : (
<div className="flex min-w-0 items-center gap-1.5">
<h2
className="m-0 min-w-0 break-words font-bold leading-snug tracking-[-0.01em] text-content"
style={fs(17, 'subtitle')}
>
{place.name}
</h2>
{canEdit && onUpdatePlace && (
<Tooltip label={t('common.edit')}>
<button
type="button"
onClick={() => {
setNameDraft(place.name);
setEditingName(true);
}}
aria-label={t('common.edit')}
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"
>
<Pencil size={12} strokeWidth={2.2} />
</button>
</Tooltip>
)}
</div>
)}
<TourNameHeading place={place} onUpdatePlace={canEdit ? onUpdatePlace : undefined} />
<TourHeadPills
tour={tour}
trackColor={trackColor}
timeFields={timeFields}
shownMinutes={times.plannedTotalMinutes ?? times.walkingMinutes}
times={times}
metrics={{
distanceKm: analysis?.distanceKm ?? tour.distance,
gain: analysis ? analysis.gain : tour.elevation_gain,
@@ -643,6 +676,7 @@ export default function TourDetailDialog({
)}
</DialogSection>
{tour.route_pending && <WalkRoutePendingHint />}
{analysis && (
<TourDetailMeasures
analysis={analysis}
@@ -652,6 +686,17 @@ export default function TourDetailDialog({
placeId={place.id}
/>
)}
{tour.tour_type === 'walk' && (
<WalkStationsCard
tour={tour}
tripId={place.trip_id}
assignment={assignment}
canMark={canAssign}
days={days}
assignments={assignments}
route={analysis?.routeCoordinates ?? null}
/>
)}
{tour.tour_type === 'kayak' && <PaddleCheckSection tour={tour} />}
{tour.tour_type === 'kayak' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
@@ -65,11 +65,17 @@ function sumBadge(
): ScopeBadge | null {
if (sum.value === null) return null;
const partial = sum.known < sum.of;
const value = partial ? `≥ ${format(sum.value)}` : format(sum.value);
const label = partial
? `${name}: ${value} (${t('tours.scope.partialBody', { known: sum.known, of: sum.of })})`
: `${name}: ${value}`;
return { key, icon, value, label };
const value = partial || sum.atLeast ? `≥ ${format(sum.value)}` : format(sum.value);
if (partial)
return {
key,
icon,
value,
label: `${name}: ${value} (${t('tours.scope.partialBody', { known: sum.known, of: sum.of })})`,
};
// CW-02: known for every tour, but a city walk's stay is not entered yet.
const said = sum.atLeast ? t('tours.walk.totalAtLeast', { time: format(sum.value) }) : value;
return { key, icon, value, label: `${name}: ${said}` };
}
function typeBadges(scope: TourTypeScope, unit: Parameters<typeof formatDistance>[1], t: Translate): ScopeBadge[] {
@@ -92,7 +98,12 @@ function typeBadges(scope: TourTypeScope, unit: Parameters<typeof formatDistance
t
),
sumBadge(
{ key: 'planned', icon: CalendarClock, name: t('tours.scope.planned') },
// A city walk plans its stays, not breaks (CW-02).
{
key: 'planned',
icon: CalendarClock,
name: t(scope.type === 'walk' ? 'tours.scope.plannedWithStays' : 'tours.scope.planned'),
},
scope.plannedMinutes,
formatPlannedTourDuration,
t
@@ -20,7 +20,7 @@ import {
} from '../../shared/DialogShell';
import { TourDayRowPill } from '../TourDayRowFacts';
import { RailFacts, TourNotice, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration, tourPlannedTimes } from '../tourPresentation';
import { plannedTotalText, tourPlannedTimes } from '../tourPresentation';
import { type TourLibrary } from './useTourLibrary';
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -30,14 +30,17 @@ const ALL = 'all';
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
function measureBadges(tour: TourLibraryItem, unit: Unit, t: Translate) {
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
const times = tourPlannedTimes(tour);
const total = plannedTotalText(times, t);
const badge = plannedTotalText(times, t, true);
return [
minutes != null && {
key: 'time',
icon: Clock,
value: formatPlannedTourDuration(minutes),
label: `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(minutes)}`,
},
total != null &&
badge != null && {
key: 'time',
icon: Clock,
value: badge,
label: `${t('tours.planner.plannedTotalDuration')}: ${total}`,
},
tour.distance != null && {
key: 'distance',
icon: Ruler,
@@ -180,13 +183,13 @@ function ListFilters({ library }: { library: TourLibrary }) {
/** The facts of the chosen tour as the preview shows them, each only where the tour has it. */
function previewFacts(tour: TourLibraryItem, waypoints: number, unit: Unit, t: Translate): TourMetricField[] {
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
const total = plannedTotalText(tourPlannedTimes(tour), t);
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
return [
fact(true, t('tours.library.fromTrip'), () => tour.trip.title),
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
fact(minutes != null, t('tours.planner.plannedTotalDuration'), () => formatPlannedTourDuration(minutes ?? 0)),
fact(total != null, t('tours.planner.plannedTotalDuration'), () => total ?? ''),
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
formatElevation(tour.elevation_gain ?? 0, unit)
),
@@ -7,7 +7,7 @@ import { tourLibraryRepo } from '../../../repo/tourLibraryRepo';
import { TourLibraryDialog } from './TourLibraryDialog';
import { useTourLibrary } from './useTourLibrary';
// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-008
// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-009
vi.mock('../../Map/MapViewAuto', () => ({
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
@@ -189,6 +189,31 @@ describe('TourLibraryDialog', () => {
expect(screen.queryByTestId('tour-library-preview')).toBeNull();
});
it('FE-TOUR-LIBRARY-009: plans a city walk with its stays, at least that much while a stay is not entered (CW-02)', async () => {
const walk = item({
place_id: 13,
name: 'Old town',
tour_type: 'walk',
duration: 45,
break_additional_minutes: 15,
station_count: 3,
station_stay_minutes: 90,
station_stays_unknown: 1,
});
list.mockResolvedValue({ ...LIST, tours: [walk] });
detail.mockResolvedValue({ tour: walk, line: [], waypoint_count: 3 });
render(<Harness />);
fireEvent.click(screen.getByTestId('tour-rail-library'));
const row = await screen.findByTestId('tour-library-row-13');
const time = within(row).getByTestId('tour-library-time');
expect(time).toHaveTextContent('≥ 2 h 15 min');
expect(time).toHaveAccessibleName('Planned total duration: at least 2 h 15 min');
fireEvent.click(row);
expect(
await within(screen.getByTestId('tour-library-preview')).findByText('at least 2 h 15 min')
).toBeInTheDocument();
});
it('FE-TOUR-LIBRARY-008: says when the other trips have no tours', async () => {
list.mockResolvedValue({ tours: [], trips: [], truncated: false });
render(<Harness />);
@@ -0,0 +1,179 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { StationDialog } from './StationDialog';
import { stationMarkNote, stationRingClass } from './stationMarks';
import { StationPills, StationShield } from './stationParts';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
const cathedral: TourPlannerWaypoint = {
id: 'p1',
lat: 48.1,
lng: 11.5,
role: 'via',
key: 'stop-cathedral',
name: 'Cathedral',
stayMinutes: 30,
importance: 'must',
protected: true,
openingHours: 'Mo-Su 09:00-18:00',
};
function open(point: TourPlannerWaypoint, fallbackName?: string) {
const onSave = vi.fn();
const onClose = vi.fn();
render(<StationDialog point={point} fallbackName={fallbackName} onSave={onSave} onClose={onClose} />);
return { onSave, onClose };
}
const stayField = () => screen.getByLabelText('Stay in minutes');
const nameField = () => screen.getByLabelText('Label');
describe('StationDialog (CW-02, CW-03, CW-10)', () => {
it('starts from the station as it is: its name, stay, importance and protection', () => {
open(cathedral);
expect(screen.getByTestId('station-dialog')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Cathedral' })).toBeInTheDocument();
expect(nameField()).toHaveValue('Cathedral');
expect(stayField()).toHaveValue('30');
expect(screen.getByRole('button', { name: 'Must see' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Protected' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('A change proposed later keeps it in the walk.')).toBeInTheDocument();
});
it('names a station without a name by the row it stands in, or as unnamed', () => {
open({ id: 'p2', lat: 48.11, lng: 11.51, role: 'via' }, 'Stop 2');
expect(screen.getByRole('heading', { name: 'Stop 2' })).toBeInTheDocument();
expect(nameField()).toHaveAttribute('placeholder', 'Stop 2');
expect(stayField()).toHaveValue('');
expect(screen.getByRole('button', { name: 'Regular' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Protected' })).toHaveAttribute('aria-pressed', 'false');
fireEvent.change(nameField(), { target: { value: 'Fountain' } });
expect(screen.getByRole('heading', { name: 'Fountain' })).toBeInTheDocument();
});
it('calls a station without a name and without a row name an unnamed stop', () => {
open({ id: 'p3', lat: 48.12, lng: 11.52, role: 'end' });
expect(screen.getByRole('heading', { name: 'Unnamed stop' })).toBeInTheDocument();
expect(nameField()).toHaveAttribute('placeholder', 'What is here');
});
it('takes whole minutes up to a day only, and says so', () => {
const { onSave } = open(cathedral);
for (const typed of ['abc', '2000', '12.5', '-3']) {
fireEvent.change(stayField(), { target: { value: typed } });
expect(screen.getByText('Enter whole minutes, up to a day.')).toBeInTheDocument();
expect(screen.getByTestId('station-save')).toBeDisabled();
}
fireEvent.submit(screen.getByTestId('station-dialog').closest('form') as HTMLFormElement);
expect(onSave).not.toHaveBeenCalled();
fireEvent.change(stayField(), { target: { value: '1440' } });
expect(screen.queryByText('Enter whole minutes, up to a day.')).not.toBeInTheDocument();
expect(screen.getByText('Empty means not known yet; 0 for a point you only pass.')).toBeInTheDocument();
expect(screen.getByTestId('station-save')).toBeEnabled();
// A submit of the form saves too, and keeps the page where it is.
const submitted = fireEvent.submit(screen.getByTestId('station-dialog').closest('form') as HTMLFormElement);
expect(submitted).toBe(false);
expect(onSave).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ key: 'stop-cathedral', stayMinutes: 1440 })
);
});
it('saves the edited station with its key and opening hours, a stay left empty as not known', () => {
const { onSave, onClose } = open(cathedral);
fireEvent.change(nameField(), { target: { value: ' Old cathedral ' } });
fireEvent.change(stayField(), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: 'Optional' }));
expect(screen.getByText('Can be left out when time runs short.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Protected' }));
fireEvent.click(screen.getByTestId('station-save'));
expect(onSave).toHaveBeenCalledExactlyOnceWith({
id: 'p1',
lat: 48.1,
lng: 11.5,
role: 'via',
key: 'stop-cathedral',
openingHours: 'Mo-Su 09:00-18:00',
name: 'Old cathedral',
importance: 'optional',
});
expect(onClose).not.toHaveBeenCalled();
});
it('saves 0 as a point passed through, and normal importance and a blank name as none', () => {
const { onSave } = open(cathedral);
fireEvent.change(nameField(), { target: { value: ' ' } });
fireEvent.change(stayField(), { target: { value: ' 0 ' } });
fireEvent.click(screen.getByRole('button', { name: 'Regular' }));
expect(screen.getByText('Visited as the walk goes.')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('station-save'));
expect(onSave).toHaveBeenCalledExactlyOnceWith({
id: 'p1',
lat: 48.1,
lng: 11.5,
role: 'via',
key: 'stop-cathedral',
openingHours: 'Mo-Su 09:00-18:00',
stayMinutes: 0,
protected: true,
});
});
it('cancelling closes the dialog and saves nothing', () => {
const { onSave, onClose } = open(cathedral);
fireEvent.change(nameField(), { target: { value: 'Changed' } });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledOnce();
expect(onSave).not.toHaveBeenCalled();
});
});
describe('StationPills (CW-02)', () => {
const pills = (point: Partial<TourPlannerWaypoint>) =>
render(<StationPills point={{ id: 'p', lat: 0, lng: 0, role: 'via', ...point }} />);
it('says nothing of a stay nobody entered', () => {
pills({ importance: 'must', protected: true });
expect(screen.queryByTestId('station-pills')).not.toBeInTheDocument();
});
it('names a point passed through, and a stay entered as its time alone', () => {
pills({ stayMinutes: 0 });
expect(screen.getByTestId('station-pills')).toHaveTextContent('Passing through');
});
it('shows the stay and nothing of the importance or the protection, which are on the number', () => {
pills({ stayMinutes: 45, importance: 'must', protected: true });
const row = screen.getByTestId('station-pills');
expect(row).toHaveTextContent('45');
expect(row).not.toHaveTextContent('Must see');
expect(row).not.toHaveTextContent('Protected');
});
});
describe("a station's marks on its number (CW-03, CW-10)", () => {
const t = (key: string) =>
({
'tours.walk.importance.must': 'Must see',
'tours.walk.importance.optional': 'Optional',
'tours.walk.station.protected': 'Protected',
})[key] ?? key;
it('rings a must-see in full, an optional stop dashed, and a normal one not at all', () => {
expect(stationRingClass('must')).toContain('outline-warning');
expect(stationRingClass('optional')).toContain('outline-dashed');
expect(stationRingClass(null)).toBe('');
expect(stationRingClass(undefined)).toBe('');
});
it('names the importance and the protection for the label, and nothing for a plain station', () => {
expect(stationMarkNote({ importance: 'must', protected: true }, t)).toBe('Must see, Protected');
expect(stationMarkNote({ importance: 'optional' }, t)).toBe('Optional');
expect(stationMarkNote({ protected: true }, t)).toBe('Protected');
expect(stationMarkNote({}, t)).toBeNull();
});
it('draws a protection as a shield at the corner of the number', () => {
const { container } = render(<StationShield />);
expect(container.querySelector('[data-station-protected="true"]')).toBeInTheDocument();
});
});
@@ -0,0 +1,154 @@
import type { StationImportance } from '@trek/shared';
import { MapPin, ShieldCheck } from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import { EditorField, INPUT, LABEL, Segmented } from '../../shared/dialogParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
import { parseStay, withStationFields } from './stationModel';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
type Importance = StationImportance | 'normal';
const IMPORTANCES: readonly Importance[] = ['normal', 'optional', 'must'];
/**
* CW-02, CW-03, CW-10: one station of a city walk, as a dialog: its name, how
* long it is stayed at (empty is unknown, 0 a point passed through), how much
* it matters and whether a proposed change must leave it. Saving is one step
* of the history; nothing here plans the route again.
*/
export function StationDialog({
point,
fallbackName,
onSave,
onClose,
}: {
point: TourPlannerWaypoint;
/** The name the row shows while the station has none of its own. */
fallbackName?: string;
onSave: (next: TourPlannerWaypoint) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const labelId = useId();
const nameId = useId();
const stayId = useId();
const [name, setName] = useState(point.name ?? '');
const [stay, setStay] = useState(point.stayMinutes == null ? '' : String(point.stayMinutes));
const [importance, setImportance] = useState<Importance>(point.importance ?? 'normal');
const [protectedStation, setProtectedStation] = useState(point.protected === true);
const stayMinutes = parseStay(stay);
const stayInvalid = stayMinutes === undefined;
// The footer sits outside the dialog's form: its button saves by its own click, and a submit of the form stays here.
const save = (event?: { preventDefault: () => void }) => {
event?.preventDefault();
if (stayInvalid) return;
onSave(
withStationFields(point, {
name,
stayMinutes,
importance: importance === 'normal' ? null : importance,
protected: protectedStation,
})
);
};
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
onSubmit={save}
header={
<DialogHeader
tile={
<DialogTile>
<MapPin size={16} strokeWidth={2} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('tours.walk.station.eyebrow')}
title={name.trim() || fallbackName || t('tours.walk.station.unnamed')}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={() => save()} disabled={stayInvalid} data-testid="station-save">
{t('common.save')}
</DialogButton>
</DialogFooter>
}
>
<div className="flex flex-col gap-4" data-testid="station-dialog">
<EditorField label={t('tours.walk.station.name')} htmlFor={nameId}>
<input
id={nameId}
className={INPUT}
value={name}
maxLength={200}
placeholder={fallbackName ?? t('tours.walk.station.namePlaceholder')}
onChange={(event) => setName(event.target.value)}
/>
</EditorField>
<EditorField
label={t('tours.walk.station.stay')}
htmlFor={stayId}
hint={t('tours.walk.station.stayHint')}
error={stayInvalid ? t('tours.walk.station.stayInvalid') : undefined}
>
<input
id={stayId}
className={INPUT}
inputMode="numeric"
value={stay}
placeholder={t('tours.walk.station.stayUnknown')}
onChange={(event) => setStay(event.target.value)}
/>
</EditorField>
<div className="min-w-0">
<span className={LABEL}>{t('tours.walk.station.importance')}</span>
<Segmented<Importance>
value={importance}
onChange={setImportance}
label={t('tours.walk.station.importance')}
fill
options={IMPORTANCES.map((value) => ({ value, label: t(`tours.walk.importance.${value}`) }))}
/>
<span className="mt-1.5 block text-content-faint" style={fs(11, 'body')}>
{t(`tours.walk.importanceHint.${importance}`)}
</span>
</div>
<div className="flex items-start gap-2.5">
<ShieldCheck size={15} strokeWidth={2} className="mt-0.5 flex-none text-content-faint" />
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-semibold text-content" style={fs(12.5, 'body')}>
{t('tours.walk.station.protected')}
</span>
<span className="leading-snug text-content-faint" style={fs(11, 'body')}>
{t('tours.walk.station.protectedHint')}
</span>
</span>
<ToggleSwitch
on={protectedStation}
onToggle={() => setProtectedStation((value) => !value)}
label={t('tours.walk.station.protected')}
/>
</div>
</div>
</DialogShell>
);
}
@@ -11,8 +11,20 @@ vi.mock('../library/useTourLibrary', () => ({
},
}));
vi.mock('../library/TourLibraryDialog', () => ({ TourLibraryDialog: () => <div data-testid="library-dialog" /> }));
const walk = vi.hoisted(() => ({ show: vi.fn(), options: null as unknown }));
vi.mock('../walk/useWalkImport', () => ({
useWalkImport: (options: unknown) => {
walk.options = options;
return { show: walk.show };
},
}));
vi.mock('../walk/WalkImportDialog', () => ({
WalkImportDialog: ({ hasUnsavedChanges }: { hasUnsavedChanges: boolean }) => (
<div data-testid="walk-dialog" data-unsaved={String(hasUnsavedChanges)} />
),
}));
// FE-TOUR-ADD-001 to FE-TOUR-ADD-005
// FE-TOUR-ADD-001 to FE-TOUR-ADD-006
function planner(mode: 'neutral' | 'new-draft' = 'new-draft', overrides: Record<string, unknown> = {}) {
return {
@@ -21,6 +33,7 @@ function planner(mode: 'neutral' | 'new-draft' = 'new-draft', overrides: Record<
hasUnsavedChanges: false,
openTour: vi.fn(),
startNewTour: vi.fn(),
startDerivedTour: vi.fn(),
...overrides,
} as unknown as Parameters<typeof TourAddMenu>[0]['planner'];
}
@@ -32,7 +45,7 @@ const open = () => fireEvent.click(screen.getByTestId('tour-rail-add'));
afterEach(() => vi.clearAllMocks());
describe('TourAddMenu (LIB-02)', () => {
it('FE-TOUR-ADD-001: one button opens the three ways to add a tour, in order', () => {
it('FE-TOUR-ADD-001: one button opens the four ways to add a tour, in order', () => {
render(<TourAddMenu planner={planner()} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
const button = screen.getByTestId('tour-rail-add');
expect(button).toHaveAccessibleName('Add tour');
@@ -42,6 +55,7 @@ describe('TourAddMenu (LIB-02)', () => {
expect(entries().map((entry) => entry.textContent)).toEqual([
'Plan new tour',
'Import GPX',
'Import walk from Google Maps',
'Take over a tour from another trip',
]);
});
@@ -65,7 +79,11 @@ describe('TourAddMenu (LIB-02)', () => {
it('FE-TOUR-ADD-003: with no tour open the map is already planning one, so that entry is left out', () => {
render(<TourAddMenu planner={planner('neutral')} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
open();
expect(entries().map((entry) => entry.textContent)).toEqual(['Import GPX', 'Take over a tour from another trip']);
expect(entries().map((entry) => entry.textContent)).toEqual([
'Import GPX',
'Import walk from Google Maps',
'Take over a tour from another trip',
]);
});
it('FE-TOUR-ADD-004: a tour taken over comes in, and opens unless the editor holds unsaved work', async () => {
@@ -84,13 +102,25 @@ describe('TourAddMenu (LIB-02)', () => {
expect(screen.getByTestId('library-dialog')).toBeInTheDocument();
});
it('FE-TOUR-ADD-005: waits while saving or importing, and is not there with nothing to offer', () => {
it('FE-TOUR-ADD-005: waits while saving or importing, and offers a walk from Google Maps with nothing else to offer', () => {
const { rerender } = render(<TourAddMenu planner={planner()} gpxImport={gpxImport(true)} disabled={false} />);
expect(screen.getByTestId('tour-rail-add')).toBeDisabled();
rerender(<TourAddMenu planner={planner()} gpxImport={gpxImport()} disabled />);
expect(screen.getByTestId('tour-rail-add')).toBeDisabled();
rerender(<TourAddMenu planner={planner('neutral')} disabled={false} />);
expect(screen.queryByTestId('tour-rail-add')).toBeNull();
open();
expect(entries().map((entry) => entry.textContent)).toEqual(['Import walk from Google Maps']);
expect(screen.queryByTestId('library-dialog')).toBeNull();
});
it('FE-TOUR-ADD-006: the walk entry opens its dialog, which knows about unsaved work in the planner (CW-07)', () => {
const p = planner('new-draft', { hasUnsavedChanges: true });
render(<TourAddMenu planner={p} gpxImport={gpxImport()} disabled={false} />);
expect((walk.options as { planner: unknown }).planner).toBe(p);
expect(screen.getByTestId('walk-dialog')).toHaveAttribute('data-unsaved', 'true');
open();
fireEvent.click(screen.getByText('Import walk from Google Maps'));
expect(walk.show).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('group')).toBeNull();
});
});
@@ -1,5 +1,5 @@
import type { TourListItem } from '@trek/shared';
import { FileDown, Library, Loader2, PenLine, Plus } from 'lucide-react';
import { FileDown, Footprints, Library, Loader2, PenLine, Plus } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { PopoverItem } from '../../Packing/PackingPopover';
@@ -9,6 +9,8 @@ import { usePopoverList } from '../../shared/usePopoverList';
import { TourLibraryDialog } from '../library/TourLibraryDialog';
import { useTourLibrary } from '../library/useTourLibrary';
import type { TourGpxImport } from '../useTourGpxImport';
import { WalkImportDialog } from '../walk/WalkImportDialog';
import { useWalkImport } from '../walk/useWalkImport';
import type { TourPlannerController } from './useTourPlanner';
interface AddEntry {
@@ -21,9 +23,12 @@ interface AddEntry {
/**
* LIB-02: the tours rail's one way to add a tour, as a menu: plan a new one
* (while another is open; with none open the map is already planning one),
* import GPX files, or take one over from another trip, whose dialog opens
* from here. A tour taken over comes into the trip and opens in the editor,
* unless the editor holds unsaved work.
* import GPX files, read a city walk from a Google Maps walking route (CW-07),
* or take one over from another trip. The walk and the take-over open their
* dialogs from here. A tour taken over comes into the trip and opens in the
* editor, unless the editor holds unsaved work; a walk read from Google Maps
* opens in the planner as a new draft, after a warning when that replaces
* unsaved work.
*/
export function TourAddMenu({
planner,
@@ -31,14 +36,17 @@ export function TourAddMenu({
onTakenOver,
disabled,
}: {
planner: Pick<TourPlannerController, 'tripId' | 'mode' | 'hasUnsavedChanges' | 'openTour' | 'startNewTour'>;
planner: Pick<
TourPlannerController,
'tripId' | 'mode' | 'hasUnsavedChanges' | 'openTour' | 'startNewTour' | 'startDerivedTour'
>;
gpxImport?: TourGpxImport;
/** Where a tour of another trip goes into this one; without it that entry is left out. */
onTakenOver?: (tour: TourListItem) => Promise<void>;
disabled: boolean;
}) {
const { t } = useTranslation();
const list = usePopoverList(0, { estimatedHeight: 140 });
const list = usePopoverList(0, { estimatedHeight: 176 });
const { open, toggle, close, triggerRef } = list;
const library = useTourLibrary({
tripId: Number(planner.tripId),
@@ -47,6 +55,7 @@ export function TourAddMenu({
if (!planner.hasUnsavedChanges) void planner.openTour(tour);
},
});
const walkImport = useWalkImport({ planner });
const importing = gpxImport?.importing ?? false;
const entries: AddEntry[] = [];
@@ -66,6 +75,12 @@ export function TourAddMenu({
run: gpxImport.pick,
});
}
entries.push({
key: 'walk',
icon: <Footprints size={14} strokeWidth={2} />,
label: t('tours.walkImport.menu'),
run: walkImport.show,
});
if (onTakenOver) {
entries.push({
key: 'library',
@@ -74,7 +89,6 @@ export function TourAddMenu({
run: library.show,
});
}
if (entries.length === 0) return null;
// Run inside the click that picked it: the file picker opens only from a user's own tap.
const pick = (entry: AddEntry) => {
@@ -106,6 +120,7 @@ export function TourAddMenu({
<PopoverItem key={entry.key} icon={entry.icon} label={entry.label} onClick={() => pick(entry)} />
))}
</PopoverListPanel>
<WalkImportDialog walkImport={walkImport} hasUnsavedChanges={planner.hasUnsavedChanges} />
{onTakenOver && <TourLibraryDialog library={library} />}
</>
);
@@ -79,6 +79,7 @@ export function TourNameField({ planner, locked }: TourFieldProps) {
hike: t('tours.planner.namePlaceholder'),
bike: t('tours.planner.namePlaceholder.bike'),
kayak: t('tours.planner.namePlaceholder.kayak'),
walk: t('tours.planner.namePlaceholder.walk'),
}[planner.tourType]
}
className={INPUT}
@@ -157,31 +158,34 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
const manual = planner.plannedDurationMinutes != null;
return (
<div data-testid="tour-planner-time-grid" className="grid grid-cols-2 gap-3">
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-breaks"
aside={
<InfoDot
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
}
>
{t('tours.planner.breaksShort')}
</FieldLabel>
<TourDurationInput
id="tour-planner-breaks"
label={t('tours.planner.breaksAdditional')}
value={planner.breakAdditionalMinutes}
invalid={planner.breakAdditionalInvalid}
errorId="tour-planner-breaks-error"
locked={locked}
onCommit={planner.setBreakAdditionalMinutes}
/>
{planner.breakAdditionalInvalid && (
<FieldError id="tour-planner-breaks-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
</div>
{/* CW-02: a city walk stops at its stations; their stays take the place of breaks. */}
{planner.tourType !== 'walk' && (
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-breaks"
aside={
<InfoDot
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
}
>
{t('tours.planner.breaksShort')}
</FieldLabel>
<TourDurationInput
id="tour-planner-breaks"
label={t('tours.planner.breaksAdditional')}
value={planner.breakAdditionalMinutes}
invalid={planner.breakAdditionalInvalid}
errorId="tour-planner-breaks-error"
locked={locked}
onCommit={planner.setBreakAdditionalMinutes}
/>
{planner.breakAdditionalInvalid && (
<FieldError id="tour-planner-breaks-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
</div>
)}
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-planned-duration"
@@ -129,6 +129,28 @@ describe('TourPlannerToursRail', () => {
expect(screen.getByText('1 h 20 min')).toBeInTheDocument()
})
it('CW-02: shows a walk its walking time, its stays and their total, a total entered by hand beside the computed one', () => {
const walk = {
mode: { type: 'new-draft' as const }, tourType: 'walk' as const,
walkingDurationMinutes: 45, plannedTotalMinutes: 135,
stationStays: { walkingMinutes: 45, stayMinutes: 90, unknownStays: 1, stations: 3, totalMinutes: 135, partial: true },
}
const facts = () => screen.getAllByRole('definition').map(value => [value.previousElementSibling?.textContent, value.textContent])
const view = render(<TourPlannerToursRail planner={planner(walk)} tours={[tour]} {...railProps} />)
expect(facts()).toEqual(expect.arrayContaining([
[expect.stringMatching(/^Est\. walking time/), '45 min'],
['Stays', 'at least 1 h 30 min'],
['Planned', 'at least 2 h 15 min'],
]))
expect(screen.queryByText('Breaks')).not.toBeInTheDocument()
view.rerender(<TourPlannerToursRail planner={planner({ ...walk, plannedDurationMinutes: 240, plannedTotalMinutes: 240 })} tours={[tour]} {...railProps} />)
expect(facts()).toEqual(expect.arrayContaining([
['Planned (Manual override)', '4 h'],
['Computed', 'at least 2 h 15 min'],
]))
})
it('disables saved-tour switching while a Save is pending', () => {
const controller = planner({
isSaving: true,
@@ -749,7 +771,7 @@ describe('TourPlannerRail', () => {
expect(controller.cancelNewTour).toHaveBeenCalledOnce()
rerender(<TourPlannerRail planner={controller} />)
fireEvent.click(screen.getByRole('button', { name: 'Discard changes and start new tour' }))
fireEvent.click(screen.getByRole('button', { name: 'Discard and start' }))
expect(controller.startNewTour).toHaveBeenCalledWith(true)
})
@@ -879,6 +901,17 @@ describe('TourPlannerRail', () => {
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-022: an unsaved walk read from Google Maps says so, and a saved one no longer does (CW-07)', () => {
const notice = 'From a Google Maps walking route. TREK planned the way between the stops itself.'
const { rerender } = render(<TourPlannerRail planner={planner({ draftSource: 'google_maps', hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
expect(screen.getByText(notice)).toBeInTheDocument()
rerender(<TourPlannerRail planner={planner({ draftSource: 'google_maps', editingPlaceId: tour.place_id, mode: { type: 'edit-saved', placeId: tour.place_id } })} />)
expect(screen.queryByText(notice)).toBeNull()
rerender(<TourPlannerRail planner={planner({ hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
expect(screen.queryByText(notice)).toBeNull()
})
})
describe('the paddle checklist in the rails (PAD-06)', () => {
@@ -16,7 +16,7 @@ import { BarButton, SoftPill } from '../../Planner/planParts'
import TourListRow from '../TourListRow'
import ElevationProfile from '../../shared/ElevationProfile'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection, type TourMetricField } from '../tourParts'
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
import { TourDetailsSection, TourNameField } from './TourEditorFields'
import { crossesLake } from '../paddle/lakeStyleAdvice'
@@ -41,11 +41,14 @@ import { type AssignTourToDay, useTourDayAssignment } from '../useTourDayAssignm
import WandererMark from '../wanderer/WandererMark'
import { TourGpxExportBarButton } from '../TourGpxExportButton'
import { TourRemoteChangeNotice, TourRevisionConflictDialog } from './TourRevisionNotice'
import { WalkImportDraftNotice } from '../walk/WalkImportDialog'
import { WalkRoutePendingNotice } from '../walk/WalkRoutePending'
import TourListFilterMenu, { TourFilterEmpty } from '../TourListFilterMenu'
import { TourScopeSummary } from '../TourScopeSummary'
import { useTourListFilters } from '../useTourListFilters'
import { TourPoiSearchCard } from '../poi/TourPoiSearchCard'
import { BikeWaysCard } from '../bike/BikeWaysCard'
import { walkTimeFields } from '../walk/walkTimeFields'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
dirty: 'tours.planner.status.dirty',
@@ -437,6 +440,8 @@ export function TourPlannerRail({ planner,
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
<TourRemoteChangeNotice planner={planner} />
<WalkImportDraftNotice planner={planner} />
<WalkRoutePendingNotice planner={planner} />
<TourNameField planner={planner} locked={locked} />
<TourTypeFields planner={planner} locked={locked} />
@@ -612,6 +617,7 @@ const ESTIMATE_KEYS = {
hike: { labelKey: 'tours.planner.walkingEstimated', shortKey: 'tours.planner.walkingEstimatedShort', infoLabelKey: 'tours.planner.walkingEstimatedInfoLabel', infoKey: 'tours.planner.walkingEstimatedInfo' },
bike: { labelKey: 'tours.bike.timeEstimated', shortKey: 'tours.bike.timeEstimatedShort', infoLabelKey: 'tours.bike.timeEstimatedInfoLabel', infoKey: 'tours.bike.timeEstimatedInfo' },
kayak: { labelKey: 'tours.paddle.timeEstimated', shortKey: 'tours.paddle.timeEstimatedShort', infoLabelKey: 'tours.paddle.timeEstimatedInfoLabel', infoKey: 'tours.paddle.timeEstimatedInfo' },
walk: { labelKey: 'tours.walk.timeEstimated', shortKey: 'tours.walk.timeEstimatedShort', infoLabelKey: 'tours.walk.timeEstimatedInfoLabel', infoKey: 'tours.walk.timeEstimatedInfo' },
} as const
/**
@@ -642,14 +648,25 @@ function UnreachableNotice({ planner }: { planner: TourPlannerController }) {
)
}
/** The time facts of the overview: the estimate (walking, riding or paddling), the breaks and the planned total. */
function plannerTimeFields(planner: TourPlannerController, t: Translate) {
/**
* The time facts of the overview: the estimate (walking, riding or paddling),
* the breaks and the planned total; a city walk its walking time, its stays and
* their total in the wording the tour detail uses too (CW-02).
*/
function plannerTimeFields(planner: TourPlannerController, t: Translate): TourMetricField[] {
const keys = ESTIMATE_KEYS[planner.tourType]
const estimate = {
label: t(keys.labelKey),
shortLabel: t(keys.shortKey),
info: { label: t(keys.infoLabelKey), description: t(keys.infoKey) },
}
if (planner.stationStays) {
return walkTimeFields({ walking: estimate, walkingMinutes: planner.walkingDurationMinutes, stays: planner.stationStays,
manualMinutes: planner.plannedDurationMinutes, t })
}
return [
{
label: t(keys.labelKey),
shortLabel: t(keys.shortKey),
info: { label: t(keys.infoLabelKey), description: t(keys.infoKey) },
...estimate,
value: planner.walkingDurationMinutes == null
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.walkingDurationMinutes),
@@ -660,7 +677,9 @@ function plannerTimeFields(planner: TourPlannerController, t: Translate) {
},
{
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
value: planner.plannedTotalMinutes == null
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.plannedTotalMinutes),
},
]
}
@@ -44,6 +44,23 @@ describe('TourTypeFields (PAD-01)', () => {
expect(field).toHaveAttribute('aria-expanded', 'false');
});
it('CW-01: a city walk is a type of its own, on foot from stop to stop, with no fields of a bike or a boat', () => {
const hike = planner({ tourType: 'hike', isPaddle: false });
const { unmount } = render(<TourTypeFields planner={hike} locked={false} />);
fireEvent.click(screen.getByTestId('tour-type-select'));
const option = screen.getByTestId('tour-type-option-walk');
expect(option).toHaveTextContent('City / sightseeing walk');
expect(option).toHaveTextContent('On foot from stop to stop, without a hiking limit');
fireEvent.click(option);
expect(hike.setTourType).toHaveBeenCalledWith('walk');
unmount();
render(<TourTypeFields planner={planner({ tourType: 'walk', isPaddle: false })} locked={false} />);
expect(screen.getByTestId('tour-type-select')).toHaveTextContent('City / sightseeing walk');
expect(screen.queryByTestId('tour-planner-paddle-fields')).not.toBeInTheDocument();
expect(screen.queryByTestId('tour-planner-bike-fields')).not.toBeInTheDocument();
});
it('BIK-01 to BIK-04: a bike tour names its profile like a boat, with what the profile asks for', () => {
const bike = planner({ tourType: 'bike', isPaddle: false, isBike: true, bikeProfile: 'gravel' });
render(<TourTypeFields planner={bike} locked={false} />);
@@ -1,11 +1,15 @@
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-006: shaping points and the water router's answers in the list (P2A-03, P2A-05, PAD-11).
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-011: shaping points, the water router's answers
// and a city walk's stops in the list, with their arrivals and opening hours on a day of the walk
// (P2A-03, P2A-05, PAD-11, CW-01, CW-02, CW-04).
import userEvent from '@testing-library/user-event';
import type { PaddleRouteLeg } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories';
import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTripStore } from '../../../store/tripStore';
import type { PaddleTourRoute } from './paddleRouting';
import TourWaypointList from './TourWaypointList';
import type { TourPlannerController } from './useTourPlanner';
@@ -171,4 +175,161 @@ describe('TourWaypointList', () => {
await userEvent.click(screen.getByTestId('tour-planner-route-on-water'));
expect(controller.routeDrawnLegsOnWater).toHaveBeenCalled();
});
it('FE-TOUR-WAYPOINT-LIST-007: a city walk names its stops, shows their stay and edits one stop from its menu', async () => {
const cathedral = {
...STOP,
key: 'stop-cathedral',
name: 'Cathedral',
stayMinutes: 30,
importance: 'must' as const,
};
const controller = planner({
tourType: 'walk',
waypoints: [START, SHAPE, cathedral, END],
selectedWaypointId: 'b',
});
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
expect(within(at(rows, 2)).getByTestId('tour-planner-waypoint-name')).toHaveTextContent('Cathedral');
// The stay goes under the name; a must-see rings the number instead of a pill beside it.
expect(within(at(rows, 2)).getByTestId('station-pills')).toHaveTextContent('30 min');
expect(within(at(rows, 2)).getByTestId('station-pills')).not.toHaveTextContent('Must see');
const mustSee = within(at(rows, 2)).getByLabelText('Waypoint 2, Must see');
expect(mustSee.className).toContain('outline-warning');
// A stop without a stay entered says nothing of it, and a shaping point is no stop.
expect(within(at(rows, 0)).queryByTestId('station-pills')).not.toBeInTheDocument();
expect(within(at(rows, 1)).queryByTestId('station-pills')).not.toBeInTheDocument();
await userEvent.click(within(at(rows, 2)).getByRole('button', { name: 'More actions' }));
await userEvent.click(screen.getByRole('button', { name: 'Edit stop' }));
const stay = screen.getByLabelText('Stay in minutes');
await userEvent.clear(stay);
await userEvent.type(stay, '45');
await userEvent.click(screen.getByTestId('station-save'));
expect(screen.queryByTestId('station-dialog')).not.toBeInTheDocument();
expect(controller.editWaypoints).toHaveBeenCalledTimes(1);
const update = vi.mocked(controller.editWaypoints).mock.calls[0]?.[0] as (
points: TourPlannerController['waypoints']
) => TourPlannerController['waypoints'];
expect(update([START, SHAPE, cathedral, END])).toEqual([START, SHAPE, { ...cathedral, stayMinutes: 45 }, END]);
});
it('FE-TOUR-WAYPOINT-LIST-007b: a hike has no stop pills and no stop to edit', async () => {
const controller = planner({ selectedWaypointId: 'b' });
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
expect(screen.queryByTestId('station-pills')).not.toBeInTheDocument();
await userEvent.click(
within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 2)).getByRole('button', { name: 'More actions' })
);
expect(screen.queryByRole('button', { name: 'Edit stop' })).not.toBeInTheDocument();
});
it('FE-TOUR-WAYPOINT-LIST-009: a stop taken in along the tour carries its kind of place at the corner of its number (PLN-03)', () => {
const controller = planner({ waypoints: [START, { ...STOP, poiCategory: 'restaurant' as const }, END] });
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
const number = within(at(rows, 1)).getByLabelText('Waypoint 2, Places to eat');
expect(number.querySelector('[data-waypoint-poi="restaurant"]')).toBeInTheDocument();
// A point set by hand has no kind to show.
expect(within(at(rows, 0)).queryByLabelText(/Places to eat/)).not.toBeInTheDocument();
expect(document.querySelectorAll('[data-waypoint-poi]')).toHaveLength(1);
});
it('FE-TOUR-WAYPOINT-LIST-008: the way back of a loop walk is no stop: no pills and no stop to edit', async () => {
const back = { ...START, id: 'back', role: 'end' as const };
const controller = planner({
tourType: 'walk',
isLoop: true,
waypoints: [{ ...START, stayMinutes: 10 }, STOP, back],
selectedWaypointId: 'back',
});
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
expect(within(at(rows, 0)).getByTestId('station-pills')).toBeInTheDocument();
expect(within(at(rows, 2)).queryByTestId('station-pills')).not.toBeInTheDocument();
await userEvent.click(within(at(rows, 2)).getByRole('button', { name: 'More actions' }));
expect(screen.queryByRole('button', { name: 'Edit stop' })).not.toBeInTheDocument();
});
describe('a city walk on a day of the plan (CW-02, CW-04)', () => {
const lenbachhaus = {
...START,
key: 'stop-lenbachhaus',
name: 'Lenbachhaus',
stayMinutes: 60,
openingHours: 'Mo-Fr 10:00-18:00',
};
const cafe = { ...STOP, name: 'Cafe', stayMinutes: 30 };
const ROUTE: Array<[number, number]> = [
[49.1, 12.1],
[49.08, 12.11],
[49.05, 12.12],
[49.0, 12.1],
];
/** A walk being edited, its route planned for the points as they are; on Monday 19 October from 17:20. */
function walkPlanner(overrides: Partial<TourPlannerController> = {}) {
return planner({
tourType: 'walk',
editingPlaceId: 42,
waypoints: [lenbachhaus, SHAPE, cafe, END],
route: ROUTE,
routeCurrent: true,
walkingDurationMinutes: 40,
...overrides,
});
}
beforeEach(() => {
const day = buildDay({ id: 7, date: '2026-10-19' });
const entry = buildAssignment({
day_id: 7,
place_id: 42,
assignment_time: '17:20',
place: buildPlace({ id: 42 }),
});
seedStore(useTripStore, { days: [day], assignments: { '7': [entry] } });
});
it('FE-TOUR-WAYPOINT-LIST-009: names the day above the stops and the conflict at the stop it is about', () => {
render(<TourWaypointList planner={walkPlanner()} canEdit isSaving={false} />);
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, Mon, Oct 19(, 2026)?, start 17:20$/);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
const first = within(at(rows, 0)).getByTestId('walk-station-schedule');
expect(within(first).getByText('Arrival about 17:20, stay until 18:20')).toBeInTheDocument();
expect(within(first).getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)'
);
// Beside the row's select button, never inside it.
expect(within(at(rows, 0)).getByRole('button', { current: false, name: /Lenbachhaus/ })).not.toContainElement(
first
);
expect(within(at(rows, 1)).queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
expect(within(at(rows, 2)).getByTestId('walk-station-schedule')).not.toHaveTextContent('closes');
expect(screen.getAllByTestId('walk-station-conflict')).toHaveLength(1);
});
it('FE-TOUR-WAYPOINT-LIST-010: shows no arrival while the route is not planned for the points as they are', () => {
render(<TourWaypointList planner={walkPlanner({ routeCurrent: false })} canEdit isSaving={false} />);
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, Mon, Oct 19(, 2026)?, start 17:20$/);
expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
});
it('FE-TOUR-WAYPOINT-LIST-011: gives the way back of a loop no arrival of its own, and a hike no day line', () => {
const back = { ...START, id: 'back', role: 'end' as const };
const loop = walkPlanner({ isLoop: true, waypoints: [lenbachhaus, STOP, back], route: ROUTE.slice(0, 3) });
const { unmount } = render(<TourWaypointList planner={loop} canEdit isSaving={false} />);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
expect(within(at(rows, 0)).getByTestId('walk-station-schedule')).toBeInTheDocument();
expect(within(at(rows, 1)).getByTestId('walk-station-schedule')).toBeInTheDocument();
expect(within(at(rows, 2)).queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
unmount();
render(<TourWaypointList planner={walkPlanner({ tourType: 'hike' })} canEdit isSaving={false} />);
expect(screen.queryByTestId('walk-day-line')).not.toBeInTheDocument();
expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
});
});
});
@@ -1,10 +1,4 @@
import {
paddleLayoutOfPoints,
type PaddleLayout,
type PaddleRegionLoadState,
type PaddleRole,
type TourFixedLeg,
} from '@trek/shared';
import { paddleLayoutOfPoints, type PaddleRole, type TourPoiCategory } from '@trek/shared';
import {
ArrowDown,
ArrowDownUp,
@@ -18,41 +12,44 @@ import {
RefreshCw,
Repeat,
Spline,
SquarePen,
Trash2,
Unlink,
Waves,
} from 'lucide-react';
import { Fragment, useState, type MouseEvent, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import {
useTourFerrataCrossingStore,
type TourFerrataCrossing,
type TourWaypointFerrata,
} from '../../../store/tourFerrataCrossingStore';
import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPlaceToolStore';
import { formatDistance } from '../../../utils/units';
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
import { SoftPill } from '../../Planner/planParts';
import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph';
import { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad';
import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow';
import { LockRow, PaddleRolePill, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts';
import { AutoPointPill } from '../paddle/PaddleLegParts';
import { PaddleRolePill } from '../paddle/PaddleWaypointParts';
import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
import { usePortageRouting, usePortageWay } from '../paddle/usePortageRouting';
import { usePortageRouting } from '../paddle/usePortageRouting';
import { RailCard } from '../tourParts';
import { StationScheduleRow } from '../walk/StationSchedule';
import { usePlannerWalkSchedule } from '../walk/useWalkSchedule';
import { WalkDayLine } from '../walk/WalkDayLine';
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
import type { PaddleLegFailure } from './paddleRouting';
import { lineLengthM } from './tourFixedLegs';
import { StationDialog } from './StationDialog';
import { stationMarkNote, stationRingClass } from './stationMarks';
import { StationPills, StationShield } from './stationParts';
import { TripPlacePicker } from './TripPlacePicker';
import type { TourPlannerController } from './useTourPlanner';
import { useTripPlaceWaypoints } from './useTripPlaceWaypoints';
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
import { WaypointLegRows } from './WaypointLegRows';
type DropTarget = { id: string; placement: 'before' | 'after' };
@@ -137,37 +134,6 @@ function RouteToolButton({
);
}
/**
* A leg drawn by hand, as a badge on that leg in the dashed look the map draws
* it in: no router planned it, and the badge says so. Its length is the line
* the user drew, the only measure it has.
*/
function FreeLegRow({ leg }: { leg: TourFixedLeg }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
// Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two".
const line = leg.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]);
const distance = formatDistance(lineLengthM(line) / 1000, unit ?? 'metric');
return (
<li data-testid="tour-planner-free-leg" className="flex min-w-0 py-0.5 ps-[34px]">
<Tooltip label={t('tours.planner.freeLegInfo')}>
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full border border-dashed border-edge bg-surface-card pe-1 ps-2">
<Spline 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(11.5, 'body')}>
{t('tours.planner.freeLegBadge')}
</span>
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{distance}
</span>
</span>
</Tooltip>
</li>
);
}
/** The quiet hint and first-use guidance before a route exists. */
function WaypointEmptyState() {
const { t } = useTranslation();
@@ -183,54 +149,57 @@ function WaypointEmptyState() {
);
}
/**
* A via ferrata the route climbs between two waypoints, as a badge on that leg:
* the ladder, its name, and its grade and length as chips inside, in the
* ferrata violet the map draws it in.
*/
function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
const length = formatDistance((crossing.toM - crossing.fromM) / 1000, unit ?? 'metric');
/** PLN-03: the kind of place a point was found as, its icon at the number's corner in its colour on the map. */
function PoiCornerIcon({ category }: { category: TourPoiCategory }) {
const look = TOUR_POI_LOOKS[category];
return (
<li
data-testid="tour-planner-ferrata-leg"
aria-label={t('tours.ferrata.crossingInfo', { length, name: crossing.name })}
className="flex min-w-0 py-0.5 ps-[34px]"
<span
aria-hidden="true"
data-waypoint-poi={category}
className="absolute -bottom-1 -start-1 grid h-4 w-4 place-items-center rounded-full ring-2 ring-[color:var(--bg-card)]"
style={{ background: `color-mix(in srgb, ${look.color} 22%, var(--bg-card))`, color: look.color }}
>
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1 ps-1 shadow-sm ring-1 ring-inset ring-[color:var(--map-ferrata)]">
<FerrataGlyph size={18} className="flex-none" />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
{crossing.name}
</span>
{crossing.grade && (
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-semibold leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{crossing.grade}
</span>
)}
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{length}
</span>
</span>
</li>
<look.Icon size={9} strokeWidth={2.6} />
</span>
);
}
/** A city walk station's own marks on its number (CW-03, CW-10); none on any other point. */
type StationMarks = Pick<WaypointPoint, 'importance' | 'protected'> | null;
/**
* What a waypoint's number says besides the number: the via ferrata it stands
* at an end of, or else the kind of place it was found as along the tour (a
* ferrata's end says more about the point, so it wins), and a station's
* importance and protection.
*/
function numberNote(
ferrata: TourWaypointFerrata | null,
poiCategory: TourPoiCategory | null,
station: StationMarks,
t: Translate
): { note: string | null; poi: TourPoiCategory | null } {
const marks = station ? stationMarkNote(station, t) : null;
const join = (first: string | null) => [first, marks].filter(Boolean).join(', ') || null;
if (ferrata) return { note: join(t('tours.ferrata.waypointAtEnd', { name: ferrata.name })), poi: null };
if (poiCategory) return { note: join(t(TOUR_POI_LOOKS[poiCategory].labelKey)), poi: poiCategory };
return { note: join(null), poi: null };
}
/**
* A waypoint's number, as the map pins show it. At the foot or the top of a via
* ferrata it takes a violet ring and the ferrata's ladder at its corner, named
* by the tooltip.
* by the tooltip. A point taken in from the places along the tour (PLN-03)
* carries that kind's icon at the same corner, in its colour on the map. A
* city walk's station shows how much it matters as a ring around the number
* and its protection as a shield at the top corner.
*/
function WaypointNumber({
stop,
selected,
ferrata,
poiCategory = null,
station = null,
loopEnd = false,
shaping = false,
}: {
@@ -238,6 +207,10 @@ function WaypointNumber({
stop: number;
selected: boolean;
ferrata: TourWaypointFerrata | null;
/** PLN-03: the kind of place the point was found as along the tour. */
poiCategory?: TourPoiCategory | null;
/** CW-03, CW-10: a station's importance and protection, drawn on the number; only a city walk's points carry them. */
station?: StationMarks;
/** The way back to the start of a loop: shown as the return, not as one more stop. */
loopEnd?: boolean;
/** P2A-03: a shaping point, a small dot as on the map. */
@@ -269,19 +242,22 @@ function WaypointNumber({
);
}
const label = t('tours.planner.waypointLabel', { n: stop });
const { note, poi } = numberNote(ferrata, poiCategory, station, t);
const number = (
<span
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
aria-label={[label, note].filter(Boolean).join(', ')}
data-waypoint-number={stop}
data-ferrata-end={ferrata ? 'true' : undefined}
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''} ${stationRingClass(station?.importance)}`}
style={fs(11.5)}
>
{stop}
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
{poi && <PoiCornerIcon category={poi} />}
{station?.protected && <StationShield />}
</span>
);
if (!ferrata) return number;
if (!ferrata) return note ? <Tooltip label={note}>{number}</Tooltip> : number;
const grade = ferrata.grade ? ` (${ferrata.grade})` : '';
return <Tooltip label={`${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}${grade}`}>{number}</Tooltip>;
}
@@ -297,6 +273,7 @@ function WaypointRowLabel({
loopEnd,
paddleRole,
linked = false,
station = false,
}: {
point: TourPlannerController['waypoints'][number];
placeName: string | undefined;
@@ -305,10 +282,12 @@ function WaypointRowLabel({
paddleRole?: PaddleRole | null;
/** PR1-10: the point stands for this place of the trip, named after it. */
linked?: boolean;
/** CW-01: a city walk's station, which shows its stay (its importance and protection are on its number). */
station?: boolean;
}) {
const { t } = useTranslation();
const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`;
const name = loopEnd ? t('tours.planner.loopEnd') : placeName;
const name = loopEnd ? t('tours.planner.loopEnd') : (point.name ?? placeName);
return (
<>
<span className="flex w-full min-w-0 items-center gap-1.5">
@@ -356,6 +335,8 @@ function WaypointRowLabel({
</span>
)
)}
{/* Under the coordinates: a station's stay, the one mark of it that is not on its number. */}
{station && !loopEnd && <StationPills point={point} />}
</>
);
}
@@ -451,6 +432,7 @@ function WaypointRowActions({
selected,
isSaving,
onUnlink,
stationName,
}: {
planner: TourPlannerController;
point: WaypointPoint;
@@ -460,11 +442,21 @@ function WaypointRowActions({
isSaving: boolean;
/** PR1-10: given for a point that stands for a place; the place itself stays. */
onUnlink?: () => void;
/** CW-01: the name the row shows, for a station without one of its own. */
stationName?: string;
}) {
const { t } = useTranslation();
const menu = useContextMenu();
const paddle = usePaddleWaypointActions(planner);
const [stationOpen, setStationOpen] = useState(false);
const station = planner.tourType === 'walk' && !point.shaping && !(planner.isLoop && index === lastIndex);
const items: MenuItems = [
...(station
? [
{ label: t('tours.walk.station.edit'), icon: SquarePen, onClick: () => setStationOpen(true) },
{ divider: true } as const,
]
: []),
{
label: t('tours.planner.moveUp'),
icon: ArrowUp,
@@ -505,57 +497,21 @@ function WaypointRowActions({
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
<ContextMenu menu={menu.menu} onClose={menu.close} />
{stationOpen && (
<StationDialog
point={point}
fallbackName={stationName}
onClose={() => setStationOpen(false)}
onSave={(next) => {
planner.editWaypoints((current) => current.map((item) => (item.id === next.id ? next : item)));
setStationOpen(false);
}}
/>
)}
</span>
);
}
/**
* What a paddle tour shows under a waypoint: the portage that starts there,
* and the lock at it with its times.
*/
function PaddleRowExtras({
planner,
point,
index,
layout,
editable,
routing,
}: {
planner: TourPlannerController;
point: TourPlannerController['waypoints'][number];
index: number;
layout: PaddleLayout;
editable: boolean;
routing: ReturnType<typeof usePortageRouting>;
}) {
const paddle = usePaddleWaypointActions(planner);
const way = usePortageWay(planner.waypoints, routing, index);
const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index);
const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined;
return (
<>
{point.carryNext && <PendingPortageRow disabled={!editable} onRemove={() => paddle.togglePortage(point.id)} />}
{portage && (
<PortageLegRow
lengthM={portage.toM - portage.fromM}
extraMinutes={portage.extraMinutes}
disabled={!editable}
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
way={point.auto ? undefined : way}
/>
)}
{point.lock && (
<LockRow
lock={point.lock}
bypassed={!!lock?.bypassed}
disabled={!editable}
onChange={(next) => paddle.setLock(point.id, next)}
/>
)}
</>
);
}
/**
* The row under the last waypoint that turns on "set points" for the map the
* planner shows, the same switch as the one in the map's layer pill; while it
@@ -669,100 +625,6 @@ function WaypointListTools({ planner, isSaving }: { planner: TourPlannerControll
);
}
/**
* A leg the router could not plan. Where it lacked the water data of the
* region (PAD-11C), the row follows the data instead: loading or asked for,
* the leg waits calmly; declined, failed or not to be had, it says why.
*/
function LegFailure({
code,
load,
disabled,
onDrawStraight,
onRetry,
onLoadRetry,
}: {
code: PaddleLegFailure;
load: PaddleRegionLoadState | undefined;
disabled: boolean;
onDrawStraight: () => void;
onRetry: () => void;
onLoadRetry: () => void;
}) {
const { t } = useTranslation();
if (isRegionLoadPending(load)) {
return <PaddleRegionLoadRow state={load} disabled={disabled} onDrawStraight={onDrawStraight} />;
}
return (
<PaddleLegFailureRow
code={code}
disabled={disabled}
onDrawStraight={onDrawStraight}
onRetry={onRetry}
message={regionLoadFailure(load, t)}
onLoadRetry={load?.state === 'failed' ? onLoadRetry : undefined}
/>
);
}
/**
* The rows under a waypoint for the leg that starts there: the via ferrata it
* crosses, a line drawn by hand, and on a paddle tour what the router found on
* the way (PAD-11) or why it could not plan it, plus the portage and lock rows.
*/
function WaypointLegRows({
planner,
point,
index,
crossings = [],
routedLegs,
paddleLayout,
editable,
portageRouting,
regionLoad,
}: {
planner: TourPlannerController;
point: WaypointPoint;
index: number;
crossings?: TourFerrataCrossing[];
routedLegs: TourPlannerController['paddleRoute'] | null;
paddleLayout: ReturnType<typeof paddleLayoutOfPoints> | null;
editable: boolean;
portageRouting: ReturnType<typeof usePortageRouting>;
regionLoad: ReturnType<typeof usePaddleRegionLoad>;
}) {
const failure = routedLegs?.failures.find((candidate) => candidate.index === index);
return (
<>
{crossings.map((crossing) => (
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
))}
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} />}
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
{failure && (
<LegFailure
code={failure.code}
load={regionLoad.byLeg.get(index)}
disabled={!editable}
onDrawStraight={() => planner.toggleWaypointFreeLeg(point.id)}
onRetry={() => planner.recomputeLeg(point.id)}
onLoadRetry={regionLoad.retry}
/>
)}
{paddleLayout && (
<PaddleRowExtras
planner={planner}
point={point}
index={index}
layout={paddleLayout}
editable={editable}
routing={portageRouting}
/>
)}
</>
);
}
export default function TourWaypointList({
planner,
canEdit,
@@ -777,6 +639,8 @@ export default function TourWaypointList({
}) {
const { t } = useTranslation();
const tripPlaces = useTripPlaceWaypoints(planner, tourPlaceIds);
// CW-02, CW-04: the arrivals and opening hours of a city walk on one of its days; hints only, nothing saved.
const walk = usePlannerWalkSchedule(planner, tripPlaces.linkedPlaceOf);
const [placesOpen, setPlacesOpen] = useState(false);
const addRows = canEdit && (
<>
@@ -847,12 +711,15 @@ export default function TourWaypointList({
return card(
<>
<WalkDayLine {...walk} className="mb-2" />
<ol data-touch-drag className="m-0 -mx-1.5 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id;
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
const loopEnd = planner.isLoop && index === lastIndex;
const linkedPlace = tripPlaces.linkedPlaceOf(point);
const placeName = linkedPlace?.name ?? placeNames[point.id];
const stop = stopNumbers.get(point.id) ?? index + 1;
return (
<Fragment key={point.id}>
<li
@@ -909,9 +776,11 @@ export default function TourWaypointList({
<GripVertical size={14} strokeWidth={1.8} />
</span>
<WaypointNumber
stop={stopNumbers.get(point.id) ?? index + 1}
stop={stop}
selected={selected}
ferrata={ferrataEnds[point.id] ?? null}
poiCategory={point.poiCategory ?? null}
station={point}
loopEnd={loopEnd}
shaping={!!point.shaping}
/>
@@ -923,10 +792,11 @@ export default function TourWaypointList({
>
<WaypointRowLabel
point={point}
placeName={linkedPlace?.name ?? placeNames[point.id]}
placeName={placeName}
loopEnd={loopEnd}
paddleRole={paddleRoleOf(point.id)}
linked={!!linkedPlace}
station={planner.tourType === 'walk' && !point.shaping}
/>
</button>
{canEdit && (
@@ -938,9 +808,11 @@ export default function TourWaypointList({
selected={selected}
isSaving={isSaving}
onUnlink={point.linkedPlaceId ? () => tripPlaces.unlink(point.id) : undefined}
stationName={placeName}
/>
)}
</div>
<StationScheduleRow schedule={walk.schedule} point={point} placeName={placeName} number={stop} />
</li>
<WaypointLegRows
planner={planner}
@@ -0,0 +1,232 @@
import type { PaddleLayout, PaddleRegionLoadState, TourFixedLeg } from '@trek/shared';
import { Spline } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { TourFerrataCrossing } from '../../../store/tourFerrataCrossingStore';
import { formatDistance } from '../../../utils/units';
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad';
import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow';
import { LockRow, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts';
import type { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
import { usePortageWay, type usePortageRouting } from '../paddle/usePortageRouting';
import type { PaddleLegFailure } from './paddleRouting';
import { lineLengthM } from './tourFixedLegs';
import type { TourPlannerController } from './useTourPlanner';
type WaypointPoint = TourPlannerController['waypoints'][number];
/**
* A leg drawn by hand, as a badge on that leg in the dashed look the map draws
* it in: no router planned it, and the badge says so. Its length is the line
* the user drew, the only measure it has.
*/
function FreeLegRow({ leg }: { leg: TourFixedLeg }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
// Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two".
const line = leg.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]);
const distance = formatDistance(lineLengthM(line) / 1000, unit ?? 'metric');
return (
<li data-testid="tour-planner-free-leg" className="flex min-w-0 py-0.5 ps-[34px]">
<Tooltip label={t('tours.planner.freeLegInfo')}>
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full border border-dashed border-edge bg-surface-card pe-1 ps-2">
<Spline 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(11.5, 'body')}>
{t('tours.planner.freeLegBadge')}
</span>
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{distance}
</span>
</span>
</Tooltip>
</li>
);
}
/**
* A via ferrata the route climbs between two waypoints, as a badge on that leg:
* the ladder, its name, and its grade and length as chips inside, in the
* ferrata violet the map draws it in.
*/
function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
const length = formatDistance((crossing.toM - crossing.fromM) / 1000, unit ?? 'metric');
return (
<li
data-testid="tour-planner-ferrata-leg"
aria-label={t('tours.ferrata.crossingInfo', { length, name: crossing.name })}
className="flex min-w-0 py-0.5 ps-[34px]"
>
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1 ps-1 shadow-sm ring-1 ring-inset ring-[color:var(--map-ferrata)]">
<FerrataGlyph size={18} className="flex-none" />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
{crossing.name}
</span>
{crossing.grade && (
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-semibold leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{crossing.grade}
</span>
)}
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{length}
</span>
</span>
</li>
);
}
/**
* What a paddle tour shows under a waypoint: the portage that starts there,
* and the lock at it with its times.
*/
function PaddleRowExtras({
planner,
point,
index,
layout,
editable,
routing,
}: {
planner: TourPlannerController;
point: TourPlannerController['waypoints'][number];
index: number;
layout: PaddleLayout;
editable: boolean;
routing: ReturnType<typeof usePortageRouting>;
}) {
const paddle = usePaddleWaypointActions(planner);
const way = usePortageWay(planner.waypoints, routing, index);
const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index);
const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined;
return (
<>
{point.carryNext && <PendingPortageRow disabled={!editable} onRemove={() => paddle.togglePortage(point.id)} />}
{portage && (
<PortageLegRow
lengthM={portage.toM - portage.fromM}
extraMinutes={portage.extraMinutes}
disabled={!editable}
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
way={point.auto ? undefined : way}
/>
)}
{point.lock && (
<LockRow
lock={point.lock}
bypassed={!!lock?.bypassed}
disabled={!editable}
onChange={(next) => paddle.setLock(point.id, next)}
/>
)}
</>
);
}
/**
* A leg the router could not plan. Where it lacked the water data of the
* region (PAD-11C), the row follows the data instead: loading or asked for,
* the leg waits calmly; declined, failed or not to be had, it says why.
*/
function LegFailure({
code,
load,
disabled,
onDrawStraight,
onRetry,
onLoadRetry,
}: {
code: PaddleLegFailure;
load: PaddleRegionLoadState | undefined;
disabled: boolean;
onDrawStraight: () => void;
onRetry: () => void;
onLoadRetry: () => void;
}) {
const { t } = useTranslation();
if (isRegionLoadPending(load)) {
return <PaddleRegionLoadRow state={load} disabled={disabled} onDrawStraight={onDrawStraight} />;
}
return (
<PaddleLegFailureRow
code={code}
disabled={disabled}
onDrawStraight={onDrawStraight}
onRetry={onRetry}
message={regionLoadFailure(load, t)}
onLoadRetry={load?.state === 'failed' ? onLoadRetry : undefined}
/>
);
}
/**
* The rows under a waypoint for the leg that starts there: the via ferrata it
* crosses, a line drawn by hand, and on a paddle tour what the router found on
* the way (PAD-11) or why it could not plan it, plus the portage and lock rows.
*/
export function WaypointLegRows({
planner,
point,
index,
crossings = [],
routedLegs,
paddleLayout,
editable,
portageRouting,
regionLoad,
}: {
planner: TourPlannerController;
point: WaypointPoint;
index: number;
crossings?: TourFerrataCrossing[];
routedLegs: TourPlannerController['paddleRoute'] | null;
paddleLayout: PaddleLayout | null;
editable: boolean;
portageRouting: ReturnType<typeof usePortageRouting>;
regionLoad: ReturnType<typeof usePaddleRegionLoad>;
}) {
const failure = routedLegs?.failures.find((candidate) => candidate.index === index);
return (
<>
{crossings.map((crossing) => (
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
))}
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} />}
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
{failure && (
<LegFailure
code={failure.code}
load={regionLoad.byLeg.get(index)}
disabled={!editable}
onDrawStraight={() => planner.toggleWaypointFreeLeg(point.id)}
onRetry={() => planner.recomputeLeg(point.id)}
onLoadRetry={regionLoad.retry}
/>
)}
{paddleLayout && (
<PaddleRowExtras
planner={planner}
point={point}
index={index}
layout={paddleLayout}
editable={editable}
routing={portageRouting}
/>
)}
</>
);
}
@@ -0,0 +1,27 @@
import type { useTranslation } from '../../../i18n';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
type Translate = ReturnType<typeof useTranslation>['t'];
/**
* CW-03: how much a station matters, as a ring around its number: a full ring
* in the warning colour for a must-see, a dashed muted one for an optional
* stop, none for a normal one.
*/
export function stationRingClass(importance: TourPlannerWaypoint['importance']): string {
if (importance === 'must') return 'outline outline-2 outline-offset-1 outline-warning';
if (importance === 'optional') return 'outline outline-2 outline-offset-1 outline-dashed outline-content-faint';
return '';
}
/** CW-03, CW-10: what the ring and the shield on a station's number say, for its label and tooltip. */
export function stationMarkNote(
point: Pick<TourPlannerWaypoint, 'importance' | 'protected'>,
t: Translate
): string | null {
const parts = [
point.importance ? t(`tours.walk.importance.${point.importance}`) : null,
point.protected ? t('tours.walk.station.protected') : null,
].filter((part): part is string => part !== null);
return parts.length ? parts.join(', ') : null;
}
@@ -0,0 +1,95 @@
// FE-WALK-TIMES-001 to FE-WALK-TIMES-006: a city walk's walking time and stays kept apart (CW-02).
import { describe, expect, it } from 'vitest';
import { parseStay, walkTimes, walkTimesOf, withStationFields } from './stationModel';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
const cathedral: TourPlannerWaypoint = { id: 'a', lat: 48.1, lng: 11.5, role: 'start', stayMinutes: 30 };
const steer: TourPlannerWaypoint = { id: 's', lat: 48.105, lng: 11.505, role: 'via', shaping: true, stayMinutes: 99 };
const market: TourPlannerWaypoint = { id: 'b', lat: 48.11, lng: 11.51, role: 'via', stayMinutes: 0 };
const park: TourPlannerWaypoint = { id: 'c', lat: 48.13, lng: 11.53, role: 'end' };
describe('walkTimes', () => {
it('FE-WALK-TIMES-001: adds the stays entered to the walking time and says what is not known', () => {
expect(walkTimes([cathedral, steer, market, park], 45)).toEqual({
walkingMinutes: 45,
stayMinutes: 30,
unknownStays: 1,
stations: 3,
totalMinutes: 75,
partial: true,
});
expect(walkTimes([cathedral, market], 20)).toMatchObject({ stations: 2, totalMinutes: 50, partial: false });
});
it("FE-WALK-TIMES-002: the way back of a loop is its start again, so the start's stay counts once", () => {
const back: TourPlannerWaypoint = { ...cathedral, id: 'back', role: 'end' };
expect(walkTimes([cathedral, market, { ...park, role: 'via' }, back], 45)).toEqual({
walkingMinutes: 45,
stayMinutes: 30,
unknownStays: 1,
stations: 3,
totalMinutes: 75,
partial: true,
});
});
it('FE-WALK-TIMES-003: 45 min of walking, a museum of 90 min and a cafe of 30 min are 2 h 45 min', () => {
const museum: TourPlannerWaypoint = { id: 'm', lat: 48.12, lng: 11.52, role: 'via', stayMinutes: 90 };
const cafe: TourPlannerWaypoint = { id: 'k', lat: 48.13, lng: 11.53, role: 'end', stayMinutes: 30 };
const start: TourPlannerWaypoint = { id: 's', lat: 48.1, lng: 11.5, role: 'start', stayMinutes: 0 };
expect(walkTimes([start, museum, cafe], 45)).toMatchObject({ totalMinutes: 165, partial: false, stations: 3 });
});
it('FE-WALK-TIMES-004: without a walking time the stays entered are a part of the whole, and nothing known is no total', () => {
expect(walkTimes([cathedral, market], null)).toMatchObject({ totalMinutes: 30, partial: true });
expect(walkTimesOf({ walkingMinutes: null, stayMinutes: 0, unknownStays: 2, stations: 2 })).toEqual({
walkingMinutes: null,
stayMinutes: 0,
unknownStays: 2,
stations: 2,
totalMinutes: null,
partial: false,
});
expect(walkTimesOf({ walkingMinutes: 40, stayMinutes: 0, unknownStays: 0, stations: 2 })).toMatchObject({
totalMinutes: 40,
partial: false,
});
});
});
describe('the station dialog model', () => {
it('FE-WALK-TIMES-005: reads a stay as typed: empty is unknown, whole minutes up to a day are taken, the rest is not', () => {
expect(parseStay('')).toBeNull();
expect(parseStay(' ')).toBeNull();
expect(parseStay('0')).toBe(0);
expect(parseStay(' 45 ')).toBe(45);
expect(parseStay('1440')).toBe(1440);
for (const text of ['1441', '12.5', '-3', 'abc', '12345']) expect(parseStay(text)).toBeUndefined();
});
it('FE-WALK-TIMES-006: puts the edited fields on the point, each left out where it is empty', () => {
const point: TourPlannerWaypoint = { ...cathedral, key: 'stop-a', openingHours: 'Mo-Su 09:00-18:00' };
expect(
withStationFields(point, { name: ' Dom ', stayMinutes: 0, importance: 'optional', protected: false })
).toEqual({
id: 'a',
lat: 48.1,
lng: 11.5,
role: 'start',
key: 'stop-a',
openingHours: 'Mo-Su 09:00-18:00',
name: 'Dom',
stayMinutes: 0,
importance: 'optional',
});
expect(withStationFields(point, { name: ' ', stayMinutes: null, importance: null, protected: true })).toEqual({
id: 'a',
lat: 48.1,
lng: 11.5,
role: 'start',
key: 'stop-a',
openingHours: 'Mo-Su 09:00-18:00',
protected: true,
});
});
});
@@ -0,0 +1,76 @@
import { walkStationsOf } from '@trek/shared';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
/**
* CW-02: a city walk's time, walking and staying kept apart. While no stay is
* entered at all, the walk is planned as its walking time alone and nothing
* calls the stays unknown. Once some are entered, one left empty is unknown,
* never nothing: the total then counts only what is known and says it is at
* least that much. A point passed through is a stay of 0, entered on purpose.
*/
export interface WalkTimes {
walkingMinutes: number | null;
/** The stays entered, summed. */
stayMinutes: number;
/** Stations whose stay nobody entered. */
unknownStays: number;
/** The walk's stations, so that some stays unknown and all of them unknown read apart. */
stations: number;
/** Walking and the known stays; the known stays alone while the walking time is not known, null while nothing is. */
totalMinutes: number | null;
/** The total leaves out a stay or the walking time that is not known. */
partial: boolean;
}
/** The times of a walk from its parts: the walking time, the stays entered, how many are unknown and of how many. */
export function walkTimesOf(parts: Omit<WalkTimes, 'totalMinutes' | 'partial'>): WalkTimes {
const { walkingMinutes, stayMinutes, unknownStays, stations } = parts;
// Without a walking time the stays entered are still part of the whole: at least that much, never nothing.
const totalMinutes = walkingMinutes !== null ? walkingMinutes + stayMinutes : stayMinutes > 0 ? stayMinutes : null;
return {
...parts,
totalMinutes,
partial: totalMinutes !== null && (someStaysUnknown({ unknownStays, stations }) || walkingMinutes === null),
};
}
/** Some stays entered and some not: none entered at all means none planned, not unknown. */
export function someStaysUnknown({ unknownStays, stations }: Pick<WalkTimes, 'unknownStays' | 'stations'>): boolean {
return unknownStays > 0 && unknownStays < stations;
}
export function walkTimes(points: readonly TourPlannerWaypoint[], walkingMinutes: number | null): WalkTimes {
// A shaping point is no station, and the way back of a loop is its start again: its stay counts once.
const stations = walkStationsOf(points);
return walkTimesOf({
walkingMinutes,
stayMinutes: stations.reduce((sum, station) => sum + (station.stayMinutes ?? 0), 0),
unknownStays: stations.filter((station) => station.stayMinutes == null).length,
stations: stations.length,
});
}
/** What the station dialog edits; the rest of the point stays as it is. */
export type StationFields = Pick<TourPlannerWaypoint, 'name' | 'stayMinutes' | 'importance' | 'protected'>;
/** The point with the fields as edited, each one left out where it is empty. */
export function withStationFields(point: TourPlannerWaypoint, fields: StationFields): TourPlannerWaypoint {
const { name: _name, stayMinutes: _stay, importance: _importance, protected: _protected, ...rest } = point;
const name = fields.name?.trim();
return {
...rest,
...(name ? { name } : {}),
...(fields.stayMinutes != null ? { stayMinutes: fields.stayMinutes } : {}),
...(fields.importance ? { importance: fields.importance } : {}),
...(fields.protected ? { protected: true } : {}),
};
}
/** A stay as typed: empty is unknown, a whole number from 0 to a day is minutes, anything else is not taken. */
export function parseStay(text: string): number | null | undefined {
const trimmed = text.trim();
if (trimmed === '') return null;
if (!/^\d{1,4}$/.test(trimmed)) return undefined;
const minutes = Number(trimmed);
return minutes <= 24 * 60 ? minutes : undefined;
}
@@ -0,0 +1,36 @@
import { Clock, ShieldCheck } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
/**
* CW-02: a city walk station's stay, where one is entered, as a quiet pill
* under its name. A stay nobody entered says nothing. How much the station
* matters and whether it is protected are drawn on its number instead
* (`stationRingClass`, `StationShield`).
*/
export function StationPills({ point }: { point: Pick<TourPlannerWaypoint, 'stayMinutes'> }) {
const { t } = useTranslation();
if (point.stayMinutes == null) return null;
return (
<span className="flex flex-wrap items-center gap-1" data-testid="station-pills">
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
{point.stayMinutes === 0 ? t('tours.walk.station.passThrough') : formatPlannedTourDuration(point.stayMinutes)}
</SoftPill>
</span>
);
}
/** CW-10: a protected station's shield, at the top corner of its number. */
export function StationShield() {
return (
<span
aria-hidden="true"
data-station-protected="true"
className="absolute -end-1.5 -top-1.5 grid h-3.5 w-3.5 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm"
>
<ShieldCheck size={9} strokeWidth={2.4} />
</span>
);
}
@@ -12,6 +12,9 @@ import {
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
import type { PaddleFieldValues } from './usePaddleFields';
/** CW-07: where a draft's points were read from outside the trip, said in the planner until it is saved. */
export type DraftSource = 'google_maps';
/**
* The tour planner's draft as it is kept in local storage between visits, and
* the check a stored draft has to pass before the planner takes it back: a
@@ -30,6 +33,8 @@ export interface DraftPayload {
editingPlaceId?: number | null;
/** The import a new tour is being planned from; carried until it is saved. */
derivedFromPlaceId?: number | null;
/** CW-07: a draft loaded from a Google Maps walk keeps saying so across a reload. */
draftSource?: DraftSource | null;
tourType?: PlannableTourType;
paddle?: PaddleFieldValues;
/** P2C-03: the revision of the tour the draft edits, as it was loaded. */
@@ -58,6 +63,10 @@ function optionalPlaceId(value: number | null | undefined): boolean {
return value == null || (Number.isInteger(value) && value > 0);
}
function optionalSource(value: unknown): boolean {
return value == null || value === 'google_maps';
}
function optionalDifficulty(value: number | null | undefined): boolean {
return value == null || (Number.isInteger(value) && value >= 1 && value <= 6);
}
@@ -97,6 +106,7 @@ export function validDraft(value: unknown): value is DraftPayload {
optionalMinutes(draft.breakAdditionalMinutes) &&
optionalPlaceId(draft.editingPlaceId) &&
optionalPlaceId(draft.derivedFromPlaceId) &&
optionalSource(draft.draftSource) &&
optionalPlaceId(draft.baseRevision) &&
validRouteChoices(draft) &&
Array.isArray(draft.waypoints) &&
@@ -13,6 +13,7 @@ import {
} from '@trek/shared';
import type { RouteGeometryAnalysis } from '../../../utils/routeGeometry';
import { hikingTimeMinutes } from '../hikingTime';
import { walkTimesOf, type WalkTimes } from './stationModel';
/** The editable fields of a saved tour as the planner opens them, each with its empty value. */
export function openedTourFields(tour: TourListItem) {
@@ -52,6 +53,11 @@ export interface TourPlannerFactsInput {
/** A paddle tour's estimate on the water; null for a hike. */
paddleEstimate: { waterMinutes: number | null; estimateMinutes: number | null } | null;
routeAnalysis: RouteGeometryAnalysis | null;
/**
* CW-02: a city walk's stays as far as known, and of how many stations; null
* for any other type, which takes its breaks.
*/
stays?: Pick<WalkTimes, 'stayMinutes' | 'unknownStays' | 'stations'> | null;
/** The router's own time, seconds: a bike tour's riding time; stands in for a hike until the heights are in. */
durationSeconds: number | null;
}
@@ -69,12 +75,23 @@ function invalidMinutes(value: number | null): boolean {
function movingMinutes(input: TourPlannerFactsInput, routerMinutes: number | null): number | null {
const { paddleEstimate, routeAnalysis } = input;
if (paddleEstimate) return paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes;
if (input.tourType === 'bike') return routerMinutes;
// A bike tour is timed by the router for its profile, a city walk by the router on foot.
if (input.tourType === 'bike' || input.tourType === 'walk') return routerMinutes;
return routeAnalysis?.gain != null && routeAnalysis.loss != null
? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss)
: routerMinutes;
}
/**
* CW-02: a city walk's times, the total its overview shows. Its stops are its
* stays and it has no breaks on top, so nothing counts twice; the stays entered
* count even while the walking time is not known. Null for any other type.
*/
function walkTimesFor(input: TourPlannerFactsInput, walkingMinutes: number | null): WalkTimes | null {
if (input.tourType !== 'walk') return null;
return walkTimesOf({ stayMinutes: 0, unknownStays: 0, stations: 0, ...input.stays, walkingMinutes });
}
/** What the type adds to a save: a hike its routing limit, a bike tour its profile, a paddle tour its boat, water and speed. */
function typeFieldsOf(input: TourPlannerFactsInput) {
const isBike = input.tourType === 'bike';
@@ -92,16 +109,17 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
hiking_preferences: null,
};
}
const isHike = input.tourType === 'hike';
return {
tour_type: input.tourType,
max_hiking_difficulty: isBike ? null : input.maxHikingDifficulty,
max_hiking_difficulty: isHike ? input.maxHikingDifficulty : null,
bike_profile: isBike ? input.bikeProfile : null,
boat_type: null,
water_context: null,
paddle_speed_kmh: null,
portage_speed_kmh: null,
paddle_lake_style: null,
hiking_preferences: isBike ? null : input.hikingPreferences,
hiking_preferences: isHike ? input.hikingPreferences : null,
};
}
@@ -114,22 +132,28 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
const { durationSeconds, breakAdditionalMinutes, plannedDurationMinutes } = input;
const routerMinutes = durationSeconds === null ? null : Math.round(durationSeconds / 60);
const walkingDurationMinutes = movingMinutes(input, routerMinutes);
const automaticPlannedTotalMinutes =
walkingDurationMinutes === null ? null : walkingDurationMinutes + (breakAdditionalMinutes ?? 0);
const walk = walkTimesFor(input, walkingDurationMinutes);
const withBreaks = walkingDurationMinutes === null ? null : walkingDurationMinutes + (breakAdditionalMinutes ?? 0);
const automaticPlannedTotalMinutes = walk ? walk.totalMinutes : withBreaks;
const { boatType, waterContext } = input.paddle;
return {
isPaddle,
websiteInvalid: input.website.trim().length > 0 && !tourWebsiteSchema.safeParse(input.website).success,
plannedDurationInvalid: invalidMinutes(plannedDurationMinutes),
breakAdditionalInvalid: invalidMinutes(breakAdditionalMinutes),
// A city walk sends no breaks (CW-02), so one left from another type cannot hold its save up.
breakAdditionalInvalid: input.tourType !== 'walk' && invalidMinutes(breakAdditionalMinutes),
paddleIncomplete: isPaddle && (boatType === null || waterContext === null),
walkingDurationMinutes,
/** CW-02: a city walk's walking time, stays and total; null for any other type. */
stationStays: walk,
automaticPlannedTotalMinutes,
plannedTotalMinutes: plannedDurationMinutes ?? automaticPlannedTotalMinutes,
plannedTotalInvalid:
plannedDurationMinutes === null &&
automaticPlannedTotalMinutes !== null &&
automaticPlannedTotalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES,
// A city walk's stops are its stays: it saves no breaks, so a cafe never counts as a stay and a break at once.
savedBreakAdditionalMinutes: input.tourType === 'walk' ? null : breakAdditionalMinutes,
// The router's own time stands in for a hike without heights; a paddle tour has only its speed.
savedDurationSeconds:
walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60,
@@ -88,6 +88,58 @@ describe('tourPlannerFacts', () => {
expect(unknown.walkingDurationMinutes).toBeNull();
expect(unknown.savedDurationSeconds).toBeNull();
});
it('CW-02: times a city walk by the router on foot, its stays on top in place of breaks, and saves no hiking limit', () => {
const analysis = { distanceKm: 4, gain: 300, loss: 300 } as TourPlannerFactsInput['routeAnalysis'];
const walk = hike({
tourType: 'walk',
routeAnalysis: analysis,
durationSeconds: 3_000,
breakAdditionalMinutes: 40,
stays: { stayMinutes: 75, unknownStays: 0, stations: 2 },
hikingPreferences: { avoid_steps: true },
});
const facts = tourPlannerFacts(walk);
expect(facts.walkingDurationMinutes).toBe(50);
expect(facts.automaticPlannedTotalMinutes).toBe(125);
expect(facts.plannedTotalMinutes).toBe(125);
expect(facts.savedDurationSeconds).toBe(3_000);
expect(facts.typeFields).toMatchObject({
tour_type: 'walk',
max_hiking_difficulty: null,
hiking_preferences: null,
bike_profile: null,
boat_type: null,
});
expect(facts.stationStays).toMatchObject({
walkingMinutes: 50,
stayMinutes: 75,
totalMinutes: 125,
partial: false,
});
// Stays nobody entered count for nothing; a total typed by hand still wins.
expect(tourPlannerFacts({ ...walk, stays: null }).automaticPlannedTotalMinutes).toBe(50);
expect(tourPlannerFacts({ ...walk, plannedDurationMinutes: 180 }).plannedTotalMinutes).toBe(180);
expect(tourPlannerFacts({ ...walk, stays: null, durationSeconds: null }).automaticPlannedTotalMinutes).toBeNull();
});
it('CW-02: a walk whose walking time is not known plans at least the stays entered, as its overview says', () => {
const walk = hike({
tourType: 'walk',
durationSeconds: null,
stays: { stayMinutes: 120, unknownStays: 1, stations: 3 },
});
const facts = tourPlannerFacts(walk);
expect(facts.walkingDurationMinutes).toBeNull();
expect(facts.plannedTotalMinutes).toBe(120);
expect(facts.stationStays).toMatchObject({ walkingMinutes: null, totalMinutes: 120, partial: true });
expect(facts.plannedTotalInvalid).toBe(false);
// Stays past the longest plan a tour may have hold the save up as the walking time would.
expect(
tourPlannerFacts({ ...walk, stays: { stayMinutes: 1_500, unknownStays: 0, stations: 2 } }).plannedTotalInvalid
).toBe(true);
expect(tourPlannerFacts(hike({ durationSeconds: 600 })).stationStays).toBeNull();
});
});
describe('usePlannerHistory', () => {
@@ -1,10 +1,17 @@
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-005: the editor's waypoint model, shaping points and drawn legs (P2A-03, PAD-11).
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-010: the editor's waypoint model, shaping points, drawn legs
// and a city walk's stations with the keys a save answers (P2A-03, PAD-11, CW-01 to CW-04).
import type { TourWaypoint } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
normalizePlannerWaypoints,
plannerWaypointsOf,
routeExtrasKey,
routeInputsKey,
sameWaypointSnapshot,
savedWaypointsOf,
tourWaypointKey,
withDrawnLegsRouted,
withSavedKeys,
withShaping,
withShapingPoint,
type TourPlannerWaypoint,
@@ -80,4 +87,140 @@ describe('tourPlannerWaypoints', () => {
expect(sameWaypointSnapshot([A, B, C], [A, B, C])).toBe(true);
expect(normalizePlannerWaypoints([C, A, B]).map((point) => point.role)).toEqual(['start', 'via', 'end']);
});
const SAVED: TourWaypoint[] = [
{
lat: 48.1,
lng: 11.5,
role: 'start',
sequence: 0,
key: 'stop-cathedral',
name: 'Cathedral',
stay_minutes: 30,
importance: 'must',
protected: true,
opening_hours: 'Mo-Su 09:00-18:00',
},
{ lat: 48.105, lng: 11.505, role: 'via', sequence: 1, key: 'shape-1', shaping: true },
{ lat: 48.11, lng: 11.51, role: 'end', sequence: 2, key: 'stop-market', stay_minutes: 0 },
];
const ids = () => {
let next = 0;
return () => `p${++next}`;
};
it("FE-TOUR-WAYPOINTS-006: a saved walk's stations come into the editor with their key, name, stay, importance and hours", () => {
const points = plannerWaypointsOf(SAVED, ids());
expect(points).toEqual([
{
id: 'p1',
lat: 48.1,
lng: 11.5,
role: 'start',
key: 'stop-cathedral',
name: 'Cathedral',
stayMinutes: 30,
importance: 'must',
protected: true,
openingHours: 'Mo-Su 09:00-18:00',
},
{ id: 'p2', lat: 48.105, lng: 11.505, role: 'via', key: 'shape-1', shaping: true },
// A stay of 0 is a point passed through, kept as such.
{ id: 'p3', lat: 48.11, lng: 11.51, role: 'end', key: 'stop-market', stayMinutes: 0 },
]);
expect(savedWaypointsOf(points, 'walk')).toEqual(SAVED);
});
it("FE-TOUR-WAYPOINTS-007: another type sends a station's key, name and hours, but no stay, importance or protection", () => {
const points = plannerWaypointsOf(SAVED, ids());
const plain = [
{
lat: 48.1,
lng: 11.5,
role: 'start',
sequence: 0,
key: 'stop-cathedral',
name: 'Cathedral',
opening_hours: 'Mo-Su 09:00-18:00',
},
{ lat: 48.105, lng: 11.505, role: 'via', sequence: 1, key: 'shape-1', shaping: true },
{ lat: 48.11, lng: 11.51, role: 'end', sequence: 2, key: 'stop-market' },
];
expect(savedWaypointsOf(points, 'hike')).toEqual(plain);
expect(savedWaypointsOf(points)).toEqual(plain);
});
it("FE-TOUR-WAYPOINTS-008: a station's own fields make another step of the history, but no other route", () => {
const edits: Array<Partial<TourPlannerWaypoint>> = [
{ name: 'Cathedral' },
{ stayMinutes: 0 },
{ importance: 'optional' },
{ protected: true },
{ openingHours: 'Mo-Fr 10:00-17:00' },
];
for (const edit of edits) {
const edited = [A, { ...B, ...edit }, C];
expect(sameWaypointSnapshot([A, B, C], edited)).toBe(false);
expect(tourWaypointKey(edited)).toBe(tourWaypointKey([A, B, C]));
expect(routeExtrasKey(edited)).toBe(routeExtrasKey([A, B, C]));
}
// The key a save answered is no change of the draft at all.
expect(sameWaypointSnapshot([A, B, C], [A, { ...B, key: 'stop-b' }, C])).toBe(true);
expect(routeExtrasKey([A, { ...B, linkedPlaceId: 4 }, C])).not.toBe(routeExtrasKey([A, B, C]));
});
it('FE-TOUR-WAYPOINTS-009: the keys a save answered go onto the points by position, and nothing new answers the same list', () => {
const points = [A, { ...B, key: 'stop-b' }, C];
const keyed = withSavedKeys(points, [
{ lat: A.lat, lng: A.lng, role: 'start', sequence: 0, key: 'stop-a' },
{ lat: B.lat, lng: B.lng, role: 'via', sequence: 1, key: 'stop-b' },
{ lat: C.lat, lng: C.lng, role: 'end', sequence: 2, key: 'stop-c' },
]);
expect(keyed.map((point) => point.key)).toEqual(['stop-a', 'stop-b', 'stop-c']);
expect(keyed[1]).toBe(points[1]);
expect(keyed.map((point) => point.id)).toEqual(['a', 'b', 'c']);
expect(
withSavedKeys(
keyed,
keyed.map((point, sequence) => ({ ...point, sequence }))
)
).toBe(keyed);
// An answer without keys, as an offline save gives, or of another length leaves the points as they are.
expect(withSavedKeys(points, savedWaypointsOf([A, B, C]))).toBe(points);
expect(withSavedKeys(points, [])).toBe(points);
});
it("FE-TOUR-WAYPOINTS-010: what a route is planned from leaves a station's own fields and its key out, and nothing else", () => {
const stations: Array<Partial<TourPlannerWaypoint>> = [
{ name: 'Cathedral' },
{ stayMinutes: 45 },
{ stayMinutes: 0 },
{ importance: 'must' },
{ protected: true },
{ openingHours: 'Mo-Su 09:00-18:00' },
{ key: 'stop-b' },
];
for (const edit of stations) expect(routeInputsKey([A, { ...B, ...edit }, C])).toBe(routeInputsKey([A, B, C]));
const routed: Array<Partial<TourPlannerWaypoint>> = [
{ lat: 49.011 },
{ lng: 12.011 },
{ shaping: true },
{ lock: { wait_minutes: 10, transit_minutes: null } },
{
fixedLeg: {
kind: 'free',
geometry: [
[49.01, 12.01],
[49.02, 12.02],
],
},
},
{ linkedPlaceId: 4 },
{ id: 'other' },
];
for (const edit of routed) expect(routeInputsKey([A, { ...B, ...edit }, C])).not.toBe(routeInputsKey([A, B, C]));
expect(routeInputsKey([A, C])).not.toBe(routeInputsKey([A, B, C]));
});
});
@@ -1,4 +1,12 @@
import type { PaddleLock, TourFixedLeg, TourWaypoint, TourWaypointRole } from '@trek/shared';
import type {
PaddleLock,
PlannableTourType,
StationImportance,
TourFixedLeg,
TourPoiCategory,
TourWaypoint,
TourWaypointRole,
} from '@trek/shared';
import type { AutoBase } from './paddleRouting';
import { keepJoinedFixedLegs } from './tourFixedLegs';
import { alignFreeLegs } from './tourRouteActions';
@@ -21,6 +29,20 @@ export interface TourPlannerWaypoint {
carryNext?: boolean;
/** PR1-10: the place of the trip this point stands for, chosen on purpose; the point keeps its own spot. */
linkedPlaceId?: number;
/** CW-03: the point's saved key, the same across saves, which a city walk's visits hang on. */
key?: string;
/** The point's own name: a city walk's station, a place found along the route. */
name?: string | null;
/** CW-02: a station's stay; null not known, 0 a point passed through. */
stayMinutes?: number | null;
/** CW-03: how much a station matters; absent is normal. */
importance?: StationImportance | null;
/** CW-10: a station a proposed change must leave where it is. */
protected?: boolean;
/** CW-04: the station's opening hours as OpenStreetMap writes them. */
openingHours?: string | null;
/** PLN-03: the kind of place the point was taken in from along the tour, whose icon its row carries. */
poiCategory?: TourPoiCategory | null;
}
/** A saved tour's waypoints as the editor holds them, each under a fresh id. */
@@ -34,6 +56,13 @@ export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () =>
...(point.lock ? { lock: point.lock } : {}),
...(point.shaping ? { shaping: true } : {}),
...(point.linked_place_id ? { linkedPlaceId: point.linked_place_id } : {}),
...(point.key ? { key: point.key } : {}),
...(point.name ? { name: point.name } : {}),
...(point.stay_minutes != null ? { stayMinutes: point.stay_minutes } : {}),
...(point.importance ? { importance: point.importance } : {}),
...(point.protected ? { protected: true } : {}),
...(point.opening_hours ? { openingHours: point.opening_hours } : {}),
...(point.poi_category ? { poiCategory: point.poi_category } : {}),
}));
}
@@ -42,7 +71,9 @@ export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () =>
* keeps and nothing of the editor's own (ids, automatic marks, a portage
* waiting for its next point).
*/
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWaypoint[] {
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[], tourType?: PlannableTourType): TourWaypoint[] {
// A stay, an importance and a protection belong to a city walk's stations; another type sends none.
const walk = tourType === 'walk';
return points.map((point, sequence) => ({
lat: point.lat,
lng: point.lng,
@@ -52,6 +83,13 @@ export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWa
...(point.lock ? { lock: point.lock } : {}),
...(point.shaping ? { shaping: true } : {}),
...(point.linkedPlaceId ? { linked_place_id: point.linkedPlaceId } : {}),
...(point.key ? { key: point.key } : {}),
...(point.name ? { name: point.name } : {}),
...(walk && point.stayMinutes != null ? { stay_minutes: point.stayMinutes } : {}),
...(walk && point.importance ? { importance: point.importance } : {}),
...(walk && point.protected ? { protected: true } : {}),
...(point.openingHours ? { opening_hours: point.openingHours } : {}),
...(point.poiCategory ? { poi_category: point.poiCategory } : {}),
}));
}
@@ -93,21 +131,82 @@ export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
}
/**
* The line, and what was planned along it that draws no line: a portage's
* time, a lock, a point the router set, the place a point stands for.
* What was planned along the line that draws no line: a portage's time, a
* lock, a point the router set, the place a point stands for. With
* {@link tourWaypointKey} it is what a route is planned from.
*/
export function routeExtrasKey(points: TourPlannerWaypoint[]): string {
return JSON.stringify(
points.map((point) => [
point.fixedLeg?.kind === 'portage' ? (point.fixedLeg.extra_minutes ?? null) : null,
point.lock ?? null,
!!point.auto,
!!point.carryNext,
point.linkedPlaceId ?? null,
])
);
}
/**
* CW-02: everything a route is planned from: every point with all it holds but
* a station's own fields (its name, stay, importance, protection, opening
* hours and the kind of place it was found as) and the key a save gave it. A change to those alone leaves the route
* as it is; any other change plans it again.
*/
export function routeInputsKey(points: readonly TourPlannerWaypoint[]): string {
return JSON.stringify(
points.map(
({
name: _name,
stayMinutes: _stay,
importance: _importance,
protected: _protected,
openingHours: _hours,
poiCategory: _kind,
key: _key,
...routed
}) => routed
)
);
}
/** CW-01 to CW-04: what a city walk's station holds besides its spot, none of which the route depends on. */
function stationFieldsKey(points: TourPlannerWaypoint[]): string {
return JSON.stringify(
points.map((point) => [
point.name ?? null,
point.stayMinutes ?? null,
point.importance ?? null,
!!point.protected,
point.openingHours ?? null,
point.poiCategory ?? null,
])
);
}
/** The same step of the history: the line, what was planned along it, and every station's own fields. */
export function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
const extras = (points: TourPlannerWaypoint[]) =>
JSON.stringify(
points.map((point) => [
point.fixedLeg?.kind === 'portage' ? (point.fixedLeg.extra_minutes ?? null) : null,
point.lock ?? null,
!!point.auto,
!!point.carryNext,
point.linkedPlaceId ?? null,
])
);
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b);
return (
tourWaypointKey(a) === tourWaypointKey(b) &&
routeExtrasKey(a) === routeExtrasKey(b) &&
stationFieldsKey(a) === stationFieldsKey(b)
);
}
/**
* CW-03: the keys a save was answered with, taken over by position. The server
* keeps the points of a save in their order and gives one sent without a key
* a key of its own; sent back with the next save, it keeps the visits made on
* that station. An answer of another length is left alone, and nothing new
* answers the same array.
*/
export function withSavedKeys(points: TourPlannerWaypoint[], saved: readonly TourWaypoint[]): TourPlannerWaypoint[] {
if (saved.length !== points.length) return points;
const keyed = points.map((point, index) => {
const key = saved[index]?.key;
return key && key !== point.key ? { ...point, key } : point;
});
return keyed.some((point, index) => point !== points[index]) ? keyed : points;
}
/** P2A-03: the points with a shaping point set into the leg after `afterId`; unchanged when there is no such leg. */
@@ -1,9 +1,10 @@
import { isClosedLoop } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
import {
alignFreeLegs,
closeLoop,
freeLegBetween,
isClosedLoop,
portageLegBetween,
reverseRoute,
setPortageExtraMinutes,
@@ -27,18 +28,6 @@ const FERRATA = {
] as [number, number][],
};
describe('isClosedLoop', () => {
it('reads a loop from the points: the end on the spot of the start', () => {
expect(isClosedLoop([A, B, { ...A, id: 'a2' }])).toBe(true);
expect(isClosedLoop([A, B, C])).toBe(false);
});
it('is no loop with two points on one spot, or none', () => {
expect(isClosedLoop([A, { ...A, id: 'a2' }])).toBe(false);
expect(isClosedLoop([])).toBe(false);
});
});
describe('closeLoop (PR1-01)', () => {
it('adds one end on the start, under a new id, and keeps every stop', () => {
const closed = closeLoop([A, B, C], 'back');
@@ -57,6 +46,23 @@ describe('closeLoop (PR1-01)', () => {
const closed = closeLoop([{ ...A, fixedLeg: freeLegBetween(A, B) }, B], 'back');
expect(closed[2].fixedLeg).toBeUndefined();
});
it("takes none of a city walk's start station onto the way back, only its spot and the place it stands for", () => {
const cathedral: TourPlannerWaypoint = {
...A,
role: 'start',
key: 'stop-cathedral',
name: 'Cathedral',
stayMinutes: 30,
importance: 'must',
protected: true,
openingHours: 'Mo-Su 09:00-18:00',
linkedPlaceId: 7,
};
const closed = closeLoop<TourPlannerWaypoint>([cathedral, { ...B, role: 'via' }, { ...C, role: 'end' }], 'back');
expect(closed[3]).toEqual({ id: 'back', lat: A.lat, lng: A.lng, role: 'start', linkedPlaceId: 7 });
expect(closed[0]).toBe(cathedral);
});
});
describe('reverseRoute (PR1-02)', () => {
@@ -79,6 +85,28 @@ describe('reverseRoute (PR1-02)', () => {
expect(isClosedLoop(reversed)).toBe(true);
});
it("keeps a city walk's start station first when its loop runs the other way, and the way back no station", () => {
const start = { ...A, key: 'stop-a', name: 'Cathedral', stayMinutes: 30, importance: 'must', protected: true };
const loop = closeLoop<RoutePoint & Partial<TourPlannerWaypoint>>(
[start as RoutePoint & Partial<TourPlannerWaypoint>, B, C],
'back'
);
const reversed = reverseRoute(loop);
expect(reversed.map((point) => point.id)).toEqual(['back', 'c', 'b', 'a']);
expect(reversed[0]).toEqual({
id: 'back',
lat: A.lat,
lng: A.lng,
key: 'stop-a',
name: 'Cathedral',
stayMinutes: 30,
importance: 'must',
protected: true,
});
expect(reversed[3]).toEqual({ id: 'a', lat: A.lat, lng: A.lng });
expect(reverseRoute(reversed)).toEqual(loop);
});
it('twice is the tour as it was', () => {
const route = [{ ...A, fixedLeg: freeLegBetween(A, B) }, { ...B, fixedLeg: FERRATA }, C];
expect(reverseRoute(reverseRoute(route))).toEqual(route);
@@ -1,4 +1,4 @@
import type { PaddleLock, TourFixedLeg } from '@trek/shared';
import { isClosedLoop, type PaddleLock, type TourFixedLeg } from '@trek/shared';
/**
* The route tools of the tour planner as plain list operations: reversing the
@@ -22,22 +22,23 @@ export interface RoutePoint {
carryNext?: boolean;
}
/** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */
const SAME_SPOT_DEG = 1e-7;
function sameSpot(a: { lat: number; lng: number }, b: { lat: number; lng: number }): boolean {
return Math.abs(a.lat - b.lat) < SAME_SPOT_DEG && Math.abs(a.lng - b.lng) < SAME_SPOT_DEG;
/** What makes the start a station of a city walk (CW-01 to CW-04), and its saved key (CW-03). */
interface StationParts {
key?: string;
name?: string | null;
stayMinutes?: number | null;
importance?: string | null;
protected?: boolean;
openingHours?: string | null;
poiCategory?: string | null;
}
/**
* Whether the tour ends where it starts. Read from the points themselves, so a
* saved loop opens as a loop again without a flag of its own; a tour of two
* points on one spot is no loop, there is nothing to return from.
*/
export function isClosedLoop(points: readonly { lat: number; lng: number }[]): boolean {
const first = points[0];
const last = points[points.length - 1];
return points.length >= 3 && !!first && !!last && sameSpot(first, last);
/** A point split into what makes it a station and the rest of it; a part it does not have is left out. */
function stationSplit<T extends RoutePoint>(point: T & StationParts): { station: StationParts; rest: T } {
const { key, name, stayMinutes, importance, protected: kept, openingHours, poiCategory, ...rest } = point;
const parts: StationParts = { key, name, stayMinutes, importance, protected: kept, openingHours, poiCategory };
const station = Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined));
return { station, rest: rest as T };
}
/**
@@ -56,11 +57,13 @@ export function withPointAppended<T extends { lat: number; lng: number; role: st
* The tour taken back from its last point to its start: one end point on the
* start's spot, not a second copy of the start among the stops. `newId` names
* it; a tour that already returns, or has nothing to return from, is left as it is.
* The way back is no station of its own, so it takes neither the start's key
* nor its name, stay, importance, protection or opening hours.
*/
export function closeLoop<T extends RoutePoint>(points: readonly T[], newId: string): T[] {
const first = points[0];
if (!first || points.length < 2 || isClosedLoop(points)) return [...points];
const { fixedLeg: _leg, lock: _lock, ...start } = first;
const { fixedLeg: _leg, lock: _lock, ...start } = stationSplit(first).rest;
return [...points, { ...start, id: newId } as T];
}
@@ -68,10 +71,11 @@ export function closeLoop<T extends RoutePoint>(points: readonly T[], newId: str
* The same tour the other way round: the last point starts it, the stops run
* backwards, and a fixed leg (a via ferrata, a hand-drawn stretch) stays on the
* stretch it covered, its line turned round with it. A loop stays a loop from
* the same spot.
* the same spot, and its start stays the station there: the way back is no
* station, so the start's key, name and stay stay first (CW-02, CW-03).
*/
export function reverseRoute<T extends RoutePoint>(points: readonly T[]): T[] {
return points.map((_, index) => {
const reversed = points.map((_, index) => {
const point = points[points.length - 1 - index];
// In the new order the leg after this point is the one that led into it before.
const before = points[points.length - 2 - index];
@@ -80,6 +84,10 @@ export function reverseRoute<T extends RoutePoint>(points: readonly T[]): T[] {
if (!leg || index === points.length - 1) return rest as T;
return { ...rest, fixedLeg: { ...leg, geometry: [...leg.geometry].reverse() } } as T;
});
if (!isClosedLoop(points)) return reversed;
const start = stationSplit(reversed[0]);
const back = stationSplit(reversed[reversed.length - 1]);
return [{ ...start.rest, ...back.station } as T, ...reversed.slice(1, -1), { ...back.rest, ...start.station } as T];
}
/** A hand-drawn leg from this point straight to the next. */
@@ -1,3 +1,4 @@
import { TOUR_MAX_SNAP_M } from '@trek/shared';
import type { TourRouteResult } from './tourRouting';
/**
@@ -17,4 +18,4 @@ export function isTourRoute(value: TourRouteResult | TourRouteUnreachable | null
}
/** How far the router may move a point onto its way before the point counts as unreachable for the tour's type. */
export const MAX_TOUR_SNAP_M = 300;
export const MAX_TOUR_SNAP_M = TOUR_MAX_SNAP_M;
@@ -12,7 +12,7 @@ vi.mock('../../Map/valhallaRoute', () => ({
valhallaTurn,
}))
import { enrichTourElevations, parseValhallaElevation, routeBikeTour, routeWalkingTour, type TourRouteResult } from './tourRouting'
import { enrichTourElevations, parseValhallaElevation, routeBikeTour, routeCityWalk, routeWalkingTour, type TourRouteResult } from './tourRouting'
/** A route as the planner reads it, for the cases that expect one. */
const route = async (...args: Parameters<typeof routeWalkingTour>) => (await routeWalkingTour(...args)) as TourRouteResult | null
@@ -229,4 +229,10 @@ describe('routing on land, bike profiles and unreachable points (BIK-01)', () =>
await routeWalkingTour([{ lat: 47.39, lng: 11.01, fixedLeg: free }, { lat: 47.392, lng: 11.012 }, { lat: 47.4, lng: 11.02 }])
).toEqual({ unreachable: 1, snappedMeters: 400 })
})
it('TOUR-ROUTING-033: a city walk goes on foot with no hiking limit, no choice of a hike, and avoids ferries (CW-01)', async () => {
valhallaRun.mockResolvedValueOnce(run([3, 4]))
expect(await routeCityWalk(waypoints)).toMatchObject({ distanceMeters: 1500, durationSeconds: 300 })
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, { use_ferry: 0 })
})
})
@@ -103,6 +103,22 @@ export async function routeWalkingTour(
}, signal)
}
/**
* CW-01: a city walk, on foot between its stations. No hiking limit is sent,
* so the router keeps to the ways it takes for walking anyway (T1), and no
* route choice of a hike. Ferries are avoided: a walk is walked, and with the
* router's default Venice was crossed on the vaporetto lines instead of the
* bridges. Where no way on land exists the router still takes the ferry.
*/
export async function routeCityWalk(
waypoints: LandWaypoint[],
signal?: AbortSignal,
): Promise<TourRouteResult | TourRouteUnreachable | null> {
return routeOnLand(waypoints, 'walking', CITY_WALK_COSTING, signal)
}
const CITY_WALK_COSTING: Readonly<CostingOptions> = { use_ferry: 0 }
/** BIK-01: a bike tour, routed for its profile; a leg drawn by hand is kept as drawn. */
export async function routeBikeTour(
waypoints: LandWaypoint[],
@@ -1,18 +1,28 @@
import { act, renderHook } from '@testing-library/react';
import type { TourCreateRequest, TourCreateResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, paddlePlan, paddleForget } =
vi.hoisted(() => ({
routeWalkingTour: vi.fn(),
enrichTourElevations: vi.fn(),
createTour: vi.fn(),
detailTour: vi.fn(),
updateTour: vi.fn(),
paddlePlan: vi.fn(),
paddleForget: vi.fn(),
}));
const {
routeWalkingTour,
routeCityWalk,
enrichTourElevations,
createTour,
detailTour,
updateTour,
paddlePlan,
paddleForget,
} = vi.hoisted(() => ({
routeWalkingTour: vi.fn(),
routeCityWalk: vi.fn(),
enrichTourElevations: vi.fn(),
createTour: vi.fn(),
detailTour: vi.fn(),
updateTour: vi.fn(),
paddlePlan: vi.fn(),
paddleForget: vi.fn(),
}));
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: paddlePlan, forget: paddleForget } }));
vi.mock('../useTourPermissions', () => ({
@@ -95,6 +105,7 @@ describe('useTourPlanner route tools', () => {
vi.useFakeTimers();
localStorage.clear();
routeWalkingTour.mockReset().mockResolvedValue(routed);
routeCityWalk.mockReset().mockResolvedValue(routed);
paddlePlan.mockReset().mockImplementation(async (_trip: number, legs: LegAsked[]) => onWater(legs));
paddleForget.mockReset();
enrichTourElevations.mockReset().mockResolvedValue(enriched);
@@ -526,4 +537,145 @@ describe('useTourPlanner route tools', () => {
);
});
});
describe("a city walk's keys and stops (CW-02, CW-03)", () => {
/** The server's answer to a save: the points in their order, a key sent kept, one missing made up. */
const answered = (body: TourCreateRequest, revision: number): TourCreateResponse => ({
tour: { ...savedTour, tour_type: 'walk', revision },
waypoints: body.waypoints.map((point, index) => ({ ...point, key: point.key ?? `new-${revision}-${index}` })),
});
/** The keys of the points a create or an update sent, the body being its last argument. */
const keysSent = (call: unknown[] | undefined) =>
(call?.[call.length - 1] as TourCreateRequest).waypoints.map((point) => point.key);
/** A walk through these points, named and routed, ready to save. */
async function walkPlanned(points: Array<[number, number]>) {
createTour.mockImplementation(async (_trip: number, body: TourCreateRequest) => answered(body, 1));
updateTour.mockImplementation(async (_trip: number, _place: number, body: TourCreateRequest) =>
answered(body, (updateTour.mock.calls.length as number) + 1)
);
const hook = plannerWith(points);
act(() => hook.result.current.setTourType('walk'));
act(() => hook.result.current.setName('Old town'));
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
return hook;
}
it('sends the keys a save answered with the next save, and plans no route again for them', async () => {
const { result } = await walkPlanned([
[48, 11],
[48.01, 11.02],
[48.02, 11],
]);
const routings = routeCityWalk.mock.calls.length;
await act(async () => {
await result.current.save();
});
expect(keysSent(createTour.mock.calls[0])).toEqual([undefined, undefined, undefined]);
expect(result.current.waypoints.map((point) => point.key)).toEqual(['new-1-0', 'new-1-1', 'new-1-2']);
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
expect(routeCityWalk).toHaveBeenCalledTimes(routings);
expect(result.current.status).toBe('saved');
expect(result.current.hasUnsavedChanges).toBe(false);
expect(result.current.canUndo).toBe(false);
act(() => result.current.setName('Old town at dusk'));
await act(async () => {
await result.current.save();
});
expect(keysSent(updateTour.mock.calls[0])).toEqual(['new-1-0', 'new-1-1', 'new-1-2']);
});
it("closes a walk without the start's key, and the way back keeps the key it was given", async () => {
const { result } = await walkPlanned([
[48, 11],
[48.01, 11.02],
[48.02, 11],
]);
await act(async () => {
await result.current.save();
});
act(() => result.current.returnToStart());
expect(result.current.waypoints.map((point) => point.key)).toEqual(['new-1-0', 'new-1-1', 'new-1-2', undefined]);
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
await act(async () => {
await result.current.save();
});
expect(result.current.waypoints.map((point) => point.key)).toEqual(['new-1-0', 'new-1-1', 'new-1-2', 'new-2-3']);
act(() => result.current.setName('Round the old town'));
await act(async () => {
await result.current.save();
});
expect(keysSent(updateTour.mock.calls[1])).toEqual(['new-1-0', 'new-1-1', 'new-1-2', 'new-2-3']);
});
it("takes a stop's stay from its dialog as a step of its own and saves it, without routing the walk again", async () => {
const { result } = await walkPlanned([
[48, 11],
[48.01, 11.02],
[48.02, 11],
]);
const routings = routeCityWalk.mock.calls.length;
const stop = must(result.current.waypoints[1]);
act(() =>
result.current.editWaypoints((points) =>
points.map((point) => (point.id === stop.id ? { ...point, name: 'Market', stayMinutes: 45 } : point))
)
);
expect(result.current.waypoints[1]).toMatchObject({ name: 'Market', stayMinutes: 45 });
expect(result.current.stationStays?.stayMinutes).toBe(45);
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
expect(routeCityWalk).toHaveBeenCalledTimes(routings);
expect(result.current.status).toBe('ready');
expect(result.current.canSave).toBe(true);
act(() => result.current.undo());
expect(result.current.waypoints[1]?.stayMinutes).toBeUndefined();
act(() => result.current.redo());
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
expect(routeCityWalk).toHaveBeenCalledTimes(routings);
await act(async () => {
await result.current.save();
});
expect((createTour.mock.calls[0]?.[1] as TourCreateRequest).waypoints[1]).toMatchObject({
name: 'Market',
stay_minutes: 45,
});
});
it('names a point of a paddle tour without planning the water again', async () => {
const { result } = plannerWith([
[48, 11],
[48.01, 11.02],
[48.02, 11],
]);
act(() => result.current.setTourType('kayak'));
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
const plans = paddlePlan.mock.calls.length;
act(() =>
result.current.editWaypoints((points) =>
points.map((point, index) => (index === 1 ? { ...point, name: 'Boathouse' } : point))
)
);
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
expect(paddlePlan).toHaveBeenCalledTimes(plans);
expect(result.current.waypoints[1]?.name).toBe('Boathouse');
expect(result.current.status).toBe('ready');
});
});
});
@@ -1,19 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { hikingPreferencesOf, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId'
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
import { closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions'
import { closeLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions'
import { walkTimes } from './stationModel'
import { hasCompleteElevation, useTourRoute } from './useTourRoute'
import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints'
import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, routeInputsKey, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, withSavedKeys, type TourPlannerWaypoint } from './tourPlannerWaypoints'
import { openedTourFields, tourPlannerFacts } from './tourPlannerFacts'
import { usePlannerHistory } from './usePlannerHistory'
import { useTourDraftStorage } from './useTourDraftStorage'
import { useTourRevisionGuard } from './useTourRevisionGuard'
import { useRouteShaping } from './useRouteShaping'
import { useTourMapView } from './useTourMapView'
import type { DraftPayload } from './tourPlannerDraft'
import type { DraftPayload, DraftSource } from './tourPlannerDraft'
import { paddleEstimateOf } from './paddlePlanOf'
import { adoptChangedPoints } from './paddleRouting'
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
@@ -25,9 +26,10 @@ export interface DerivedTourDraft {
description?: string | null
website?: string | null
difficulty: TourMaxHikingDifficulty
points: Array<{ lat: number; lng: number }>
/** The import the points were taken from. */
sourcePlaceId: number
/** The control points in order; a city walk's stop keeps the name it came with (CW-07). */
points: Array<{ lat: number; lng: number; name?: string | null }>
/** The import the points were taken from; absent for a link read from outside the trip (CW-07). */
sourcePlaceId?: number
/**
* Plan the import over in place, keeping its id, days and notes (the server
* keeps the recorded track to go back to), rather than as a new tour beside it.
@@ -39,6 +41,8 @@ export interface DerivedTourDraft {
tourType?: PlannableTourType
paddle?: PaddleFieldValues
bikeProfile?: BikeProfile
/** CW-07: where the points came from, said in the planner until the draft is saved. */
source?: DraftSource
}
export type TourPlannerStatus =
@@ -58,6 +62,13 @@ interface HistoryEntry {
tourType: PlannableTourType
}
/** CW-02: two steps plan the same route: their points alike but for a station's own fields, and the same choices. */
function sameRoute(a: HistoryEntry, b: HistoryEntry): boolean {
const { waypoints: pointsA, ...choicesA } = a
const { waypoints: pointsB, ...choicesB } = b
return routeInputsKey(pointsA) === routeInputsKey(pointsB) && JSON.stringify(choicesA) === JSON.stringify(choicesB)
}
/** Where a new tour was planned from; an edit leaves it as it was saved. */
function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } {
return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {}
@@ -125,6 +136,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const [draftRestored, setDraftRestored] = useState(Boolean(restored))
const [editingPlaceId, setEditingPlaceId] = useState<number | null>(restored?.editingPlaceId ?? null)
const [derivedFromPlaceId, setDerivedFromPlaceId] = useState<number | null>(restored?.derivedFromPlaceId ?? null)
const [draftSource, setDraftSource] = useState<DraftSource | null>(restored?.draftSource ?? null)
// CW-11: the open walk was saved with its stations only; its way is planned here and kept by the next save.
const [routePending, setRoutePending] = useState(false)
const [openingTourId, setOpeningTourId] = useState<number | null>(null)
const [saveOutcome, setSaveOutcome] = useState<TourListItem | null>(null)
const [retryToken, setRetryToken] = useState(0)
@@ -232,17 +246,22 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
// A point the router set that the paddler moves or edits is theirs from then on.
const next = normalizePlannerWaypoints(paddle ? adoptChangedPoints(current, changed) : changed, paddle)
if (sameWaypointSnapshot(current, next)) return
invalidateDetailRequest()
interruptRouting()
draftRevisionRef.current += 1
recordStep(snapshot())
waypointsRef.current = next
setWaypoints(next)
// CW-02: a station's own fields (its stay, its name) plan no route: the routing goes on, and Save stays as it was.
if (routeInputsKey(current) === routeInputsKey(next)) {
markEdited()
return
}
invalidateDetailRequest()
interruptRouting()
draftRevisionRef.current += 1
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus('dirty')
setError(null)
}, [recordStep, invalidateDetailRequest, interruptRouting, snapshot])
}, [recordStep, invalidateDetailRequest, interruptRouting, markEdited, snapshot])
// Detail laid in after the step that asked for it (a portage's way on land), with no step of its own.
const amendWaypoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
@@ -391,8 +410,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
/** A snapshot of the history put back: the route and what it depends on. */
const restoreStep = useCallback((entry: HistoryEntry) => {
// CW-02: a step that changed a station's own fields only comes back without planning the route again.
const routeKept = sameRoute(entry, snapshot())
invalidateDetailRequest()
interruptRouting()
if (!routeKept) interruptRouting()
draftRevisionRef.current += 1
waypointsRef.current = entry.waypoints
difficultyRef.current = entry.difficulty
@@ -406,9 +427,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setTourTypeState(entry.tourType)
setSelectedWaypointId(id => entry.waypoints.some(point => point.id === id) ? id : entry.waypoints[0]?.id ?? null)
setHasUnsavedChanges(true)
if (routeKept) return
setStatus('dirty')
setError(null)
}, [invalidateDetailRequest, interruptRouting])
}, [invalidateDetailRequest, interruptRouting, snapshot])
const { undo: undoStep, redo: redoStep } = history
const undo = useCallback(() => {
@@ -478,14 +500,17 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const routedLegLengths = paddleRoute && routeCurrent ? paddleRoute.legLengths : undefined
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null,
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
// CW-02: a city walk's stays, what of them is known.
const stays = useMemo(() => (tourType === 'walk' ? walkTimes(waypoints, null) : null), [tourType, waypoints])
const facts = useMemo(() => tourPlannerFacts({
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, website, plannedDurationMinutes, breakAdditionalMinutes,
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds,
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays,
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, tourType, waterContext, website])
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stays, tourType, waterContext, website])
// CW-02: a city walk's times as the overview says them, from the same derivation as its planned total.
const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes,
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, typeFields } = facts
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields, stationStays } = facts
const returnToNeutral = useCallback(() => {
if (saveInFlightRef.current) return
@@ -521,6 +546,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setEditingPlaceId(null)
editingPlaceIdRef.current = null
setDerivedFromPlaceId(null)
setDraftSource(null)
setRoutePending(false)
adoptRevision(null)
setOpeningTourId(null)
setSaveOutcome(null)
@@ -568,9 +595,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
returnToNeutral()
const type = draft.tourType ?? 'hike'
const points = normalizePlannerWaypoints(
draft.points.map(({ lat, lng }) => ({ id: randomId(), lat, lng, role: 'via' as const })),
draft.points.map(({ lat, lng, name }) => ({
id: randomId(), lat, lng, role: 'via' as const, ...(name ? { name } : {}),
})),
type === 'kayak',
)
setDraftSource(draft.source ?? null)
tourTypeRef.current = type
setTourTypeState(type)
if (draft.paddle) loadPaddle(draft.paddle)
@@ -586,12 +616,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setMaxHikingDifficultyState(draft.difficulty)
difficultyRef.current = draft.difficulty
setSelectedWaypointId(points[0].id)
if (draft.inPlace) {
if (draft.inPlace && draft.sourcePlaceId !== undefined) {
setEditingPlaceId(draft.sourcePlaceId)
editingPlaceIdRef.current = draft.sourcePlaceId
adoptRevision(draft.revision)
} else {
setDerivedFromPlaceId(draft.sourcePlaceId)
setDerivedFromPlaceId(draft.sourcePlaceId ?? null)
}
setStatus('dirty')
setHasUnsavedChanges(true)
@@ -680,7 +710,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
clearHistory()
setStatus('dirty')
clearRoute()
setHasUnsavedChanges(false)
// CW-11: a walk without its way yet is planned on opening and can be saved with it at once.
const pending = result.tour.route_pending === true
setRoutePending(pending)
setHasUnsavedChanges(pending && editPermissionRef.current)
setDraftRestored(false)
setEditingPlaceId(result.tour.place_id)
editingPlaceIdRef.current = result.tour.place_id
@@ -688,6 +721,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setMapFocusKey(key => key + 1)
setSaveOutcome(null)
setReadOnlyGpxTour(null)
setDraftSource(null)
return true
} catch {
if (requestIsCurrent() && !saveInFlightRef.current) setError('open')
@@ -730,12 +764,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
useEffect(() => {
if (!hasUnsavedChanges) return
const draft: DraftPayload = { version: 10, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
derivedFromPlaceId, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile,
derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile,
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
}
keepDraft(draft)
}, [breakAdditionalMinutes,
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
derivedFromPlaceId, draftSource, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences,
bikeProfile])
@@ -774,10 +808,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
website: parsedWebsiteOrNull(website),
...typeFields,
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
waypoints: savedWaypointsOf(waypoints),
waypoints: savedWaypointsOf(waypoints, tourType),
duration_seconds: savedDurationSeconds,
planned_duration_minutes: plannedDurationMinutes,
break_additional_minutes: breakAdditionalMinutes,
break_additional_minutes: savedBreakAdditionalMinutes,
...derivedFromField(targetPlaceId, derivedFromPlaceId),
...(expectedRevision !== null ? { expected_revision: expectedRevision } : {}),
} as const
@@ -795,14 +829,22 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
? await tourRepo.create(tripId, payload)
: await tourRepo.update(tripId, targetPlaceId, payload)
if (stillCurrent()) {
// CW-03: the keys the server gave new points stay with them, so the next save keeps their visits.
const keyed = withSavedKeys(waypointsRef.current, result.waypoints)
if (keyed !== waypointsRef.current) {
waypointsRef.current = keyed
setWaypoints(keyed)
}
forgetDraft()
adoptRevision(result.tour.revision)
if (options.name) setNameState(options.name.trim())
setHasUnsavedChanges(false)
setRoutePending(false)
setStatus('saved')
clearHistory()
setEditingPlaceId(result.tour.place_id)
editingPlaceIdRef.current = result.tour.place_id
setDraftSource(null)
setSaveOutcome(result.tour)
}
if (mountedRef.current && String(tripIdRef.current) === submittedTripId && !deletedTourPlaceIdsRef.current.has(result.tour.place_id)) {
@@ -822,9 +864,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setIsSaving(false)
}
}
}, [adoptRevision, refuseRevision, revisionBaseRef, clearHistory, breakAdditionalMinutes, canSave,
}, [adoptRevision, refuseRevision, revisionBaseRef, clearHistory, savedBreakAdditionalMinutes, canSave,
derivedFromPlaceId,
description, savedDurationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, name, onSaved, plannedDurationMinutes, forgetDraft, tripId, typeFields, waypoints, website])
description, savedDurationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, name, onSaved, plannedDurationMinutes, forgetDraft, tourType, tripId, typeFields, waypoints, website])
return {
tripId, canEdit, canAssign,
@@ -834,7 +876,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
description, setDescription, website, setWebsite, websiteInvalid,
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
breakAdditionalMinutes, setBreakAdditionalMinutes, breakAdditionalInvalid,
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid, stationStays,
waypoints, selectedWaypointId, setSelectedWaypointId,
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint, editWaypoints: commitWaypoints,
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
@@ -848,9 +890,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
startNewTour,
startDerivedTour,
derivedFromPlaceId,
draftSource,
newTourConfirmationOpen, cancelNewTour, forgetDeletedTour,
elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored, mapFocusKey,
editingPlaceId, openingTourId, openTour,
editingPlaceId, openingTourId, openTour, routePending,
saveOutcome, hasUnsavedChanges, canSave, save, discard, returnToNeutral, revision,
}
}
@@ -0,0 +1,346 @@
import { act, renderHook } from '@testing-library/react';
import type { TourCreateRequest, TourCreateResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, routeCityWalk, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(
() => ({
routeWalkingTour: vi.fn(),
routeCityWalk: vi.fn(),
enrichTourElevations: vi.fn(),
createTour: vi.fn(),
detailTour: vi.fn(),
updateTour: vi.fn(),
})
);
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
import { useTourPlanner } from './useTourPlanner';
// FE-TOUR-WALK-PLANNER-001 to FE-TOUR-WALK-PLANNER-008: a city walk's stays in the planner, edited without
// planning its route again, and saved without breaks (CW-02). FE-TOUR-WALK-PLANNER-009 and 010: a walk an
// assistant saved with its stops only, planned when it is opened (CW-11).
/** 45 minutes on foot between the stops. */
const routed = {
coordinates: [
[48, 11],
[48.01, 11.02],
] as [number, number][],
parts: [
{
coordinates: [
[48, 11],
[48.01, 11.02],
] as [number, number][],
free: false,
},
],
distanceMeters: 3200,
durationSeconds: 2700,
};
const enriched = [
[48, 11, 520],
[48.01, 11.02, 525],
] as [number, number, number][];
/** The server's answer to a save: the points in their order, each with a key. */
const answered = (body: TourCreateRequest): TourCreateResponse => ({
tour: {
place_id: 42,
name: body.name,
tour_type: 'walk',
distance: 3.2,
elevation_gain: 5,
elevation_loss: 0,
duration: 45,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: false,
caution: false,
revision: 1,
},
waypoints: body.waypoints.map((point, index) => ({ ...point, key: point.key ?? `stop-${index}` })),
});
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
}
/** The value, or a failure saying it is missing instead of a later undefined. */
function must<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error('expected a value');
return value;
}
/** A walk through three stops, named, routed and with its heights: ready to save. */
async function walkPlanned() {
const hook = renderHook(() => useTourPlanner({ tripId: 31 }));
act(() => hook.result.current.startNewTour());
for (const [lat, lng] of [
[48, 11],
[48.01, 11.02],
[48.02, 11],
])
act(() => hook.result.current.addWaypoint(lat, lng));
act(() => hook.result.current.setTourType('walk'));
act(() => hook.result.current.setName('Old town'));
await settle();
return hook;
}
type Planner = ReturnType<typeof useTourPlanner>;
/** The stay of the stop at `index` set as the station dialog sets it. */
function stayAt(planner: Planner, index: number, stayMinutes: number | null) {
const id = must(planner.waypoints[index]).id;
act(() =>
planner.editWaypoints((points: TourPlannerWaypoint[]) =>
points.map((point) => (point.id === id ? { ...point, stayMinutes } : point))
)
);
}
/** The body of the save the planner sent last. */
const lastSaved = () => createTour.mock.calls[createTour.mock.calls.length - 1]?.[1] as TourCreateRequest;
describe('useTourPlanner, city walks (CW-02)', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
routeWalkingTour.mockReset().mockResolvedValue(routed);
routeCityWalk.mockReset().mockResolvedValue(routed);
enrichTourElevations.mockReset().mockResolvedValue(enriched);
createTour.mockReset().mockImplementation(async (_trip: number, body: TourCreateRequest) => answered(body));
detailTour.mockReset();
updateTour.mockReset();
});
afterEach(() => vi.useRealTimers());
it('FE-TOUR-WALK-PLANNER-001: a stay changes the time overview at once, and the route is planned once', async () => {
const { result } = await walkPlanned();
expect(routeCityWalk).toHaveBeenCalledTimes(1);
expect(result.current.walkingDurationMinutes).toBe(45);
expect(result.current.stationStays).toMatchObject({
stayMinutes: 0,
unknownStays: 3,
stations: 3,
totalMinutes: 45,
});
stayAt(result.current, 1, 90);
stayAt(result.current, 2, 30);
stayAt(result.current, 0, 0);
// Nothing waits for a route: the planner stays ready to save after each stay.
expect(result.current.status).toBe('ready');
expect(result.current.canSave).toBe(true);
expect(result.current.stationStays).toMatchObject({
walkingMinutes: 45,
stayMinutes: 120,
unknownStays: 0,
totalMinutes: 165,
partial: false,
});
expect(result.current.plannedTotalMinutes).toBe(165);
await settle();
expect(routeCityWalk).toHaveBeenCalledTimes(1);
expect(enrichTourElevations).toHaveBeenCalledTimes(1);
expect(result.current.status).toBe('ready');
expect(result.current.routeCurrent).toBe(true);
});
it('FE-TOUR-WALK-PLANNER-002: one undo takes a stay back, still without routing again', async () => {
const { result } = await walkPlanned();
stayAt(result.current, 1, 90);
expect(result.current.canUndo).toBe(true);
act(() => result.current.undo());
expect(result.current.waypoints[1]?.stayMinutes).toBeUndefined();
expect(result.current.stationStays?.stayMinutes).toBe(0);
expect(result.current.status).toBe('ready');
expect(result.current.canSave).toBe(true);
act(() => result.current.redo());
expect(result.current.waypoints[1]?.stayMinutes).toBe(90);
await settle();
expect(routeCityWalk).toHaveBeenCalledTimes(1);
});
it('FE-TOUR-WALK-PLANNER-003: saves the stays and no breaks, so a stay never counts as a break too', async () => {
const { result } = await walkPlanned();
// A break kept from the hike this draft was, out of range besides: it neither counts nor holds the save up.
act(() => result.current.setBreakAdditionalMinutes(5000));
expect(result.current.breakAdditionalInvalid).toBe(false);
stayAt(result.current, 1, 90);
stayAt(result.current, 2, 0);
expect(result.current.canSave).toBe(true);
await act(async () => {
await result.current.save();
});
const body = lastSaved();
expect(body.break_additional_minutes).toBeNull();
expect(body.waypoints.map((point) => point.stay_minutes)).toEqual([undefined, 90, 0]);
expect(body.duration_seconds).toBe(2700);
expect(result.current.status).toBe('saved');
});
it('FE-TOUR-WALK-PLANNER-004: a hike keeps its breaks: they are saved and checked as before', async () => {
const { result } = await walkPlanned();
act(() => result.current.setTourType('hike'));
await settle();
act(() => result.current.setBreakAdditionalMinutes(5000));
expect(result.current.breakAdditionalInvalid).toBe(true);
expect(result.current.canSave).toBe(false);
act(() => result.current.setBreakAdditionalMinutes(20));
await act(async () => {
await result.current.save();
});
expect(lastSaved().break_additional_minutes).toBe(20);
});
it('FE-TOUR-WALK-PLANNER-005: moving a stop still plans the route again', async () => {
const { result } = await walkPlanned();
const stop = must(result.current.waypoints[1]);
act(() => {
void result.current.setWaypointPosition(stop.id, 48.011, 11.021);
});
expect(result.current.status).toBe('dirty');
expect(result.current.canSave).toBe(false);
await settle();
expect(routeCityWalk).toHaveBeenCalledTimes(2);
expect(result.current.status).toBe('ready');
});
it('FE-TOUR-WALK-PLANNER-006: a stay set while the route is being planned lets that planning finish', async () => {
let answer: (value: typeof routed) => void = () => undefined;
routeCityWalk.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
})
);
const hook = renderHook(() => useTourPlanner({ tripId: 31 }));
act(() => hook.result.current.startNewTour());
act(() => hook.result.current.addWaypoint(48, 11));
act(() => hook.result.current.addWaypoint(48.01, 11.02));
act(() => hook.result.current.setTourType('walk'));
act(() => hook.result.current.setName('Old town'));
await settle();
expect(hook.result.current.status).toBe('routing');
stayAt(hook.result.current, 1, 30);
expect(hook.result.current.status).toBe('routing');
await act(async () => {
answer(routed);
await vi.advanceTimersByTimeAsync(0);
});
await settle();
expect(routeCityWalk).toHaveBeenCalledTimes(1);
expect(hook.result.current.status).toBe('ready');
expect(hook.result.current.canSave).toBe(true);
expect(hook.result.current.plannedTotalMinutes).toBe(75);
});
it('FE-TOUR-WALK-PLANNER-007: a stay changed after a save makes the walk ready to save again, its route as it was', async () => {
const { result } = await walkPlanned();
await act(async () => {
await result.current.save();
});
expect(result.current.status).toBe('saved');
expect(result.current.canSave).toBe(false);
stayAt(result.current, 2, 30);
expect(result.current.status).toBe('ready');
expect(result.current.hasUnsavedChanges).toBe(true);
expect(result.current.canSave).toBe(true);
await settle();
expect(routeCityWalk).toHaveBeenCalledTimes(1);
});
it("FE-TOUR-WALK-PLANNER-008: a loop run the other way keeps its start's stay, counted once", async () => {
const { result } = await walkPlanned();
stayAt(result.current, 0, 20);
act(() => result.current.returnToStart());
await settle();
expect(result.current.stationStays).toMatchObject({ stayMinutes: 20, stations: 3 });
act(() => result.current.reverse());
expect(result.current.isLoop).toBe(true);
expect(result.current.waypoints[0]?.stayMinutes).toBe(20);
expect(result.current.waypoints[3]?.stayMinutes).toBeUndefined();
expect(result.current.stationStays).toMatchObject({ stayMinutes: 20, stations: 3, unknownStays: 2 });
});
/** A saved walk of two stops as the detail answers it, with or without its way. */
const savedWalk = (routePending: boolean) => ({
tour: {
place_id: 42,
name: 'Briefed walk',
tour_type: 'walk' as const,
distance: routePending ? null : 3.2,
elevation_gain: null,
elevation_loss: null,
duration: routePending ? null : 45,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: false,
caution: false,
has_waypoints: true,
revision: 1,
route_pending: routePending,
},
waypoints: [
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0, key: 'stop-cathedral', name: 'Cathedral' },
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1, key: 'stop-park', name: 'Park' },
],
});
it('FE-TOUR-WALK-PLANNER-009: a walk saved with its stops only is planned on opening and can be saved with its way at once', async () => {
detailTour.mockResolvedValue(savedWalk(true));
updateTour.mockImplementation(async (_trip: number, _place: number, body: TourCreateRequest) => answered(body));
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
await act(async () => {
await result.current.openTour({ place_id: 42 });
});
expect(result.current.routePending).toBe(true);
expect(result.current.hasUnsavedChanges).toBe(true);
await settle();
expect(routeCityWalk).toHaveBeenCalledTimes(1);
expect(result.current.status).toBe('ready');
expect(result.current.canSave).toBe(true);
await act(async () => {
await result.current.save();
});
expect(updateTour).toHaveBeenCalledWith(
31,
42,
expect.objectContaining({ route_geometry: enriched, tour_type: 'walk', expected_revision: 1 })
);
expect(result.current.routePending).toBe(false);
expect(result.current.hasUnsavedChanges).toBe(false);
expect(result.current.status).toBe('saved');
});
it('FE-TOUR-WALK-PLANNER-010: a walk with its way opens with nothing to save', async () => {
detailTour.mockResolvedValue(savedWalk(false));
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
await act(async () => {
await result.current.openTour({ place_id: 42 });
});
await settle();
expect(result.current.routePending).toBe(false);
expect(result.current.hasUnsavedChanges).toBe(false);
expect(result.current.canSave).toBe(false);
});
});
@@ -0,0 +1,174 @@
import { act, renderHook } from '@testing-library/react';
import type { TourCreateRequest, TourCreateResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, routeCityWalk, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(
() => ({
routeWalkingTour: vi.fn(),
routeCityWalk: vi.fn(),
enrichTourElevations: vi.fn(),
createTour: vi.fn(),
detailTour: vi.fn(),
updateTour: vi.fn(),
})
);
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
import { validDraft } from './tourPlannerDraft';
import { useTourPlanner, type DerivedTourDraft } from './useTourPlanner';
// FE-TOUR-WALK-IMPORT-PLANNER-001 to FE-TOUR-WALK-IMPORT-PLANNER-004: a walk read from a Google Maps link,
// opened as a new draft with its stop names, routed on foot by TREK and saved like any other walk (CW-07).
const routed = {
coordinates: [
[41.1425899, -8.6092477],
[41.1382776, -8.6117899],
] as [number, number][],
parts: [
{
coordinates: [
[41.1425899, -8.6092477],
[41.1382776, -8.6117899],
] as [number, number][],
free: false,
},
],
distanceMeters: 1400,
durationSeconds: 1200,
};
const enriched = [
[41.1425899, -8.6092477, 80],
[41.1382776, -8.6117899, 10],
] as [number, number, number][];
const answered = (body: TourCreateRequest): TourCreateResponse => ({
tour: {
place_id: 51,
name: body.name,
tour_type: 'walk',
distance: 1.4,
elevation_gain: 5,
elevation_loss: 75,
duration: 20,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: false,
caution: false,
revision: 1,
},
waypoints: body.waypoints.map((point, index) => ({ ...point, key: point.key ?? `stop-${index}` })),
});
/** What the walk import hands the planner for a route of three stops, the middle one given as coordinates. */
const FROM_GOOGLE: DerivedTourDraft = {
name: 'Walk from Google Maps',
difficulty: 2,
points: [
{ lat: 41.1425899, lng: -8.6092477, name: 'Igreja de Santa Clara' },
{ lat: 41.1428531, lng: -8.6111165, name: null },
{ lat: 41.1382776, lng: -8.6117899, name: 'Ribeira de Gaia' },
],
inPlace: false,
tourType: 'walk',
source: 'google_maps',
};
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
}
const storedDraft = (tripId: number) => JSON.parse(localStorage.getItem(`tour-draft-${tripId}`) ?? 'null') as unknown;
describe('useTourPlanner, a walk read from Google Maps (CW-07)', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
routeWalkingTour.mockReset().mockResolvedValue(routed);
routeCityWalk.mockReset().mockResolvedValue(routed);
enrichTourElevations.mockReset().mockResolvedValue(enriched);
createTour.mockReset().mockImplementation(async (_trip: number, body: TourCreateRequest) => answered(body));
detailTour.mockReset();
updateTour.mockReset();
});
afterEach(() => vi.useRealTimers());
it('FE-TOUR-WALK-IMPORT-PLANNER-001: opens as a new walk with its stop names, routed on foot by TREK', async () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
let started = false;
act(() => {
started = result.current.startDerivedTour(FROM_GOOGLE);
});
expect(started).toBe(true);
expect(result.current.mode).toEqual({ type: 'new-draft' });
expect(result.current.tourType).toBe('walk');
expect(result.current.name).toBe('Walk from Google Maps');
expect(result.current.draftSource).toBe('google_maps');
expect(result.current.derivedFromPlaceId).toBeNull();
expect(result.current.editingPlaceId).toBeNull();
expect(result.current.waypoints.map((point) => [point.name, point.role])).toEqual([
['Igreja de Santa Clara', 'start'],
[undefined, 'via'],
['Ribeira de Gaia', 'end'],
]);
await settle();
expect(routeCityWalk).toHaveBeenCalledTimes(1);
expect(routeWalkingTour).not.toHaveBeenCalled();
expect(result.current.status).toBe('ready');
});
it('FE-TOUR-WALK-IMPORT-PLANNER-002: saves as a walk with the stop names and no import it was derived from', async () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
act(() => {
result.current.startDerivedTour(FROM_GOOGLE);
});
await settle();
expect(result.current.canSave).toBe(true);
await act(async () => {
await result.current.save();
});
const body = createTour.mock.calls[0]?.[1] as TourCreateRequest;
expect(body.tour_type).toBe('walk');
expect(body.waypoints.map((point) => point.name ?? null)).toEqual([
'Igreja de Santa Clara',
null,
'Ribeira de Gaia',
]);
expect(body).not.toHaveProperty('derived_from_place_id');
// Saved, the walk is TREK's own: the notice of where it came from goes.
expect(result.current.draftSource).toBeNull();
expect(result.current.mode).toEqual({ type: 'edit-saved', placeId: 51 });
});
it('FE-TOUR-WALK-IMPORT-PLANNER-003: the source is kept with the unsaved draft across a reload, and forgotten when it is thrown away', () => {
const first = renderHook(() => useTourPlanner({ tripId: 32 }));
act(() => {
first.result.current.startDerivedTour(FROM_GOOGLE);
});
expect(storedDraft(32)).toMatchObject({ draftSource: 'google_maps', tourType: 'walk' });
first.unmount();
const reloaded = renderHook(() => useTourPlanner({ tripId: 32 }));
expect(reloaded.result.current.draftSource).toBe('google_maps');
expect(reloaded.result.current.waypoints[0]?.name).toBe('Igreja de Santa Clara');
act(() => reloaded.result.current.returnToNeutral());
expect(reloaded.result.current.draftSource).toBeNull();
expect(storedDraft(32)).toBeNull();
});
it('FE-TOUR-WALK-IMPORT-PLANNER-004: a stored draft names no source, or Google Maps; any other is not taken back', () => {
const draft = { version: 10, name: 'Walk', waypoints: [] };
expect(validDraft(draft)).toBe(true);
expect(validDraft({ ...draft, draftSource: null })).toBe(true);
expect(validDraft({ ...draft, draftSource: 'google_maps' })).toBe(true);
expect(validDraft({ ...draft, draftSource: 'komoot' })).toBe(false);
});
});
@@ -6,14 +6,29 @@ import type {
PlannableTourType,
TourMaxHikingDifficulty,
} from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
type Dispatch,
type SetStateAction,
} from 'react';
import { randomId } from '../../../utils/randomId';
import { planPaddleStep, type PaddleTourRoute } from './paddleRouting';
import { normalizePlannerWaypoints, tourWaypointKey, type TourPlannerWaypoint } from './tourPlannerWaypoints';
import {
normalizePlannerWaypoints,
routeInputsKey,
tourWaypointKey,
type TourPlannerWaypoint,
} from './tourPlannerWaypoints';
import { isTourRoute, type TourRouteUnreachable } from './tourRouteOutcome';
import {
enrichTourElevations,
routeBikeTour,
routeCityWalk,
routeWalkingTour,
type TourElevationPoint,
type TourRoutePart,
@@ -36,7 +51,7 @@ export interface TourRouteInputs {
retryToken: number;
/** The planner's error, so a point found unreachable is only named while that is the error. */
error: string | null;
setStatus: (status: TourPlannerStatus) => void;
setStatus: Dispatch<SetStateAction<TourPlannerStatus>>;
setError: (error: string | null) => void;
onCleared: () => void;
/** Points the router set itself (a paddle tour's), laid in without a step of the history. */
@@ -52,6 +67,8 @@ interface PlannedRoute {
distanceMeters: number | null;
durationSeconds: number | null;
forKey: string | null;
/** The run it was planned in: its points with what was planned along them, its choices and the retry. */
forRun: string | null;
}
const NO_ROUTE: PlannedRoute = {
@@ -62,12 +79,21 @@ const NO_ROUTE: PlannedRoute = {
distanceMeters: null,
durationSeconds: null,
forKey: null,
forRun: null,
};
/** A route planned whole: a paddle route without a failed leg, a route on land with its heights. */
function routeComplete(planned: PlannedRoute): boolean {
return planned.paddleRoute
? planned.paddleRoute.failures.length === 0
: hasCompleteElevation(planned.enrichedGeometry);
}
/** One planning run: what it plans from, whether it went stale, and where it writes. */
interface RouteRun {
inputs: Omit<TourRouteInputs, 'retryToken' | 'error' | 'onCleared'>;
plannedKey: string;
runKey: string;
signal: AbortSignal;
stale: () => boolean;
setPlanned: (next: PlannedRoute | ((current: PlannedRoute) => PlannedRoute)) => void;
@@ -75,7 +101,7 @@ interface RouteRun {
}
// PAD-11: a paddle tour is planned on the water by the server's router; its heights are no concern.
async function routePaddle({ inputs, plannedKey, signal, stale, setPlanned }: RouteRun) {
async function routePaddle({ inputs, plannedKey, runKey, signal, stale, setPlanned }: RouteRun) {
const { waypoints, tripId, paddleBoat, paddleLakeStyle, setStatus, setError } = inputs;
setStatus('routing');
const profile = { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle } as const;
@@ -91,6 +117,7 @@ async function routePaddle({ inputs, plannedKey, signal, stale, setPlanned }: Ro
distanceMeters: planned.distanceMeters,
durationSeconds: null,
forKey: plannedKey,
forRun: runKey,
});
const failed = planned.failures.length > 0;
setStatus(failed ? 'routing-failed' : 'ready');
@@ -98,13 +125,15 @@ async function routePaddle({ inputs, plannedKey, signal, stale, setPlanned }: Ro
}
// A hike or a bike tour on land, then the heights along it.
async function routeOnLand({ inputs, plannedKey, signal, stale, setPlanned, setUnreachable }: RouteRun) {
async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlanned, setUnreachable }: RouteRun) {
const { waypoints, tourType, bikeProfile, maxHikingDifficulty, hikingPreferences, setStatus, setError } = inputs;
setStatus('routing');
const routed =
tourType === 'bike'
? await routeBikeTour(waypoints, bikeProfile, signal)
: await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
: tourType === 'walk'
? await routeCityWalk(waypoints, signal)
: await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
if (stale()) return;
if (!isTourRoute(routed)) {
setUnreachable(routed);
@@ -121,6 +150,7 @@ async function routeOnLand({ inputs, plannedKey, signal, stale, setPlanned, setU
distanceMeters: routed.distanceMeters,
durationSeconds: routed.durationSeconds,
forKey: plannedKey,
forRun: runKey,
});
setStatus('enriching-elevation');
@@ -180,16 +210,29 @@ export function useTourRoute(inputs: TourRouteInputs) {
// What a route was planned for besides its points: a route of another type or profile is not this one's.
const choices = [tourType, bikeProfile, maxHikingDifficulty, hikingPreferences, paddleBoat, paddleLakeStyle];
const plannedKey = `${waypointKey}#${JSON.stringify(choices)}`;
// CW-02, CW-03: a station's own fields and the keys a save answered are no part of what a route is planned from,
// so a change to them alone plans nothing again.
const routeInputs = useMemo(() => routeInputsKey(waypoints), [waypoints]);
const runKey = `${plannedKey}#${routeInputs}#${retryToken}`;
// The points as they are when a run starts, their station fields included, read without making them a reason to run.
const currentPoints = useEffectEvent(() => waypoints);
// The route shown was planned whole for exactly this run, so a change that leaves it as it is plans nothing again.
const routeStands = useEffectEvent(() => planned.forRun === runKey && routeComplete(planned));
useEffect(() => {
if (waypoints.length < 2) {
const points = currentPoints();
if (points.length < 2) {
interruptRouting();
setStatus(waypoints.length === 0 ? 'empty' : 'dirty');
setStatus(points.length === 0 ? 'empty' : 'dirty');
clearRoute();
onCleared();
setError(null);
return;
}
if (routeStands()) {
setStatus((current) => (current === 'dirty' ? 'ready' : current));
return;
}
const generation = ++generationRef.current;
const controller = new AbortController();
@@ -199,7 +242,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
const run: RouteRun = {
inputs: {
tripId,
waypoints,
waypoints: points,
tourType,
maxHikingDifficulty,
hikingPreferences,
@@ -211,6 +254,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
patchWaypoints,
},
plannedKey,
runKey,
signal: controller.signal,
stale,
setPlanned,
@@ -241,14 +285,15 @@ export function useTourRoute(inputs: TourRouteInputs) {
patchWaypoints,
plannedKey,
retryToken,
routeInputs,
runKey,
setError,
setStatus,
tourType,
tripId,
waypoints,
]);
const { forKey, ...shown } = planned;
const { forKey, forRun: _run, ...shown } = planned;
return {
...shown,
/** The route shown was planned for these points and these choices. */
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react';
import { useTripStore } from '../../../store/tripStore';
import type { Category, Place } from '../../../types';
import { randomId } from '../../../utils/randomId';
import { useLinkedPlaceOf } from '../walk/useWalkSchedule';
import { unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
import { withPointAppended } from './tourRouteActions';
import type { TourPlannerController } from './useTourPlanner';
@@ -49,7 +50,7 @@ export function useTripPlaceWaypoints(planner: TourPlannerController, tourPlaceI
const categories = useTripStore((state) => state.categories) ?? NO_CATEGORIES;
const { editWaypoints, setSelectedWaypointId, waypoints, route } = planner;
const placesById = useMemo(() => new Map(places.map((place) => [place.id, place])), [places]);
const linkedPlaceOf = useLinkedPlaceOf();
const candidates = useMemo((): TripPlaceCandidate[] => {
const line = tourLine({ route, waypoints });
@@ -87,13 +88,6 @@ export function useTripPlaceWaypoints(planner: TourPlannerController, tourPlaceI
[editWaypoints]
);
/** The place a point stands for while the trip still has it. */
const linkedPlaceOf = useCallback(
(point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>) =>
point.linkedPlaceId ? (placesById.get(point.linkedPlaceId) ?? null) : null,
[placesById]
);
return { candidates, add, unlink, linkedPlaceOf };
}
@@ -0,0 +1,252 @@
import type { TourPoi, WalkTheme } from '@trek/shared';
import { WALK_THEMES } from '@trek/shared';
import { Clock, ExternalLink, MoveHorizontal, Plus, Route, Star, X } from 'lucide-react';
import { useCallback, useRef } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourPoiStore } from '../../../store/tourPoiStore';
import { formatDistance } from '../../../utils/units';
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
import { useMapCardDismiss } from '../../Map/useMapCardDismiss';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { PillSelect } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
import { usePoiInsertion } from './usePoiInsertion';
import { useSelectedPoi } from './useTourPoiSearch';
type Unit = Parameters<typeof formatDistance>[1];
const km = (metres: number) => metres / 1000;
/** The point a place would follow, picked from the tour's points, with why one cannot take it. */
function AfterPicker({
planner,
poi,
insertion,
}: {
planner: TourPlannerController;
poi: TourPoi;
insertion: ReturnType<typeof usePoiInsertion>;
}) {
const { t } = useTranslation();
const names = useWaypointPlaceNames(planner.waypoints);
const afterId = insertion.afterIdFor(poi);
const options = planner.waypoints.map((point, index) => ({
value: point.id,
label: `${index + 1}. ${point.shaping ? t('tours.planner.shapingPoint') : (names[point.id] ?? `${point.lat.toFixed(4)}, ${point.lng.toFixed(4)}`)}`,
}));
const block = afterId ? insertion.blockOf(afterId) : 'none';
const locked = !planner.canEdit || planner.isSaving;
return (
<div className="mt-3 flex flex-col gap-2 border-t border-edge-faint pt-3" data-testid="tour-poi-insert">
<div className="flex min-w-0 items-center gap-2">
<span className="flex-none text-content-muted" style={fs(11.5, 'body')}>
{t('tours.poi.addAfter')}
</span>
<div className="min-w-0 flex-1">
<PillSelect
quiet
label={t('tours.poi.addAfter')}
value={afterId ?? ''}
options={options}
onChange={(value) => insertion.chooseAfter(poi, value)}
/>
</div>
</div>
{block && (
<p className="m-0 text-warning" style={fs(11, 'body')}>
{t(`tours.poi.blocked.${block}`)}
</p>
)}
<button
type="button"
disabled={!!block || locked}
onClick={() => insertion.insert(poi)}
data-testid="tour-poi-add"
title={t('tours.poi.notAPlace')}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
style={fs(12, 'body')}
>
<Plus size={14} strokeWidth={2.2} />
{t('tours.poi.add')}
</button>
</div>
);
}
/** What the source says about the place: its address, its opening hours as written, and its website. */
function PoiFacts({ poi }: { poi: TourPoi }) {
const { t } = useTranslation();
if (!poi.address && !poi.opening_hours && !poi.website) return null;
return (
<div className="mt-2 flex flex-col gap-1.5">
{poi.address && (
<p className="m-0 break-words text-content-secondary" style={fs(11.5, 'body')}>
{poi.address}
</p>
)}
{poi.opening_hours && (
<p className="m-0 flex items-start gap-1.5 text-content-secondary" style={fs(11.5, 'body')}>
<Clock size={12} strokeWidth={2.2} className="mt-0.5 flex-none text-content-faint" />
<span className="min-w-0 break-words" title={t('tours.poi.hoursHint')}>
{poi.opening_hours}
</span>
</p>
)}
{poi.website && (
<a
href={poi.website}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 self-start text-content-secondary underline-offset-2 hover:underline"
style={fs(11.5, 'body')}
>
<ExternalLink size={12} strokeWidth={2.2} />
{t('tours.poi.website')}
</a>
)}
</div>
);
}
/**
* PLN-02/PLN-03: a place found along the tour, picked on the map or in the
* list, in the look of the other cards over the planner map: what it is, how
* far from the route and how far along it, why a city walk's theme suggested
* it, where the data comes from, and the way to take it in as a stop.
*/
function TourPoiCard({
poi,
theme,
planner,
insertion,
unit,
onClose,
width,
}: {
poi: TourPoi;
theme: WalkTheme | null;
planner: TourPlannerController;
insertion: ReturnType<typeof usePoiInsertion>;
unit: Unit;
onClose: () => void;
width?: number;
}) {
const { t } = useTranslation();
const look = TOUR_POI_LOOKS[poi.category];
const kind = t(look.labelKey);
const suggested = theme && (WALK_THEMES[theme] as readonly string[]).includes(poi.category) ? theme : null;
return (
<article
className={`max-h-[70vh] overflow-y-auto overscroll-contain rounded-2xl p-3 text-content ${width ? '' : 'w-[min(20rem,calc(100vw_-_2rem))]'}`}
style={{ ...MAP_CARD_GLASS, width }}
data-testid="tour-poi-card-map"
>
<div className="flex items-start gap-2">
{/* The category's own map colour, as its pin on the map has it: a light wash, the icon in full. */}
<span
className="grid h-7 w-7 flex-none place-items-center rounded-full"
style={{ background: `color-mix(in srgb, ${look.color} 16%, transparent)`, color: look.color }}
>
<look.Icon size={14} strokeWidth={2.2} />
</span>
<div className="min-w-0 flex-1">
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={poi.name || kind}>
{poi.name || kind}
</h3>
{(poi.name || poi.highlight) && (
<div className="flex min-w-0 items-center gap-1.5">
{poi.name && <p className="min-w-0 truncate text-caption text-content-muted">{kind}</p>}
{/* One of the best known places along the route, as the star in the list marks it. */}
{poi.highlight && (
<span className="flex-none" title={t('tours.poi.highlightHint')} data-testid="tour-poi-highlight-badge">
<SoftPill
tone="warning"
icon={<Star size={10} strokeWidth={2.2} fill="currentColor" aria-hidden="true" />}
>
{t('tours.poi.highlight')}
</SoftPill>
</span>
)}
</div>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="flex-none rounded-lg p-1 text-content-muted hover:bg-surface-hover hover:text-content"
>
<X size={14} aria-hidden="true" />
</button>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<WhiteBadge icon={<MoveHorizontal size={10} strokeWidth={2.2} className="text-content-faint" />}>
{t('tours.poi.fromRoute', { distance: formatDistance(km(poi.distance_m), unit) })}
</WhiteBadge>
<WhiteBadge icon={<Route size={10} strokeWidth={2.2} className="text-content-faint" />}>
{t('tours.poi.along', { distance: formatDistance(km(poi.along_m), unit) })}
</WhiteBadge>
</div>
<PoiFacts poi={poi} />
<AfterPicker planner={planner} poi={poi} insertion={insertion} />
<p className="mt-2 flex flex-wrap gap-x-3 text-content-faint" style={fs(10.5, 'body')}>
{suggested && <span>{t('tours.walk.themeReason', { theme: t(`tours.walk.theme.${suggested}`) })}</span>}
<span>{t(poi.source === 'trek-places' ? 'tours.poi.source.trek' : 'tours.poi.source.osm')}</span>
</p>
</article>
);
}
/**
* The chosen place's card over the planner map, where a via ferrata's or a
* paddle object's card sits and closing as those do: on a press outside it
* and on Escape, the press handed on so the map leaves its click alone. The
* choice lives in the places store, so a pin on the map and a row in the list
* open the same card.
*/
export default function TourPoiOverlay({
planner,
onOutsidePress,
insetEnd,
bottom,
width,
}: {
planner: TourPlannerController;
onOutsidePress?: () => void;
insetEnd: number;
bottom: number;
width?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const poi = useSelectedPoi();
const theme = useTourPoiStore((state) => state.theme);
const select = useTourPoiStore((state) => state.select);
const insertion = usePoiInsertion(planner);
const close = useCallback(() => select(null), [select]);
useMapCardDismiss(ref, !!poi, close, onOutsidePress);
if (!poi) return null;
return (
<div
ref={ref}
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
data-testid="tour-poi-overlay"
>
<TourPoiCard
poi={poi}
theme={theme}
planner={planner}
insertion={insertion}
unit={unit}
onClose={close}
width={width}
/>
</div>
);
}
@@ -1,16 +1,20 @@
import type { TourPoi, TourPoiCategory } from '@trek/shared';
import type { TourPoi, WalkTheme } from '@trek/shared';
import { TOUR_POI_CORRIDORS_M } from '@trek/shared';
import {
AlertTriangle,
Clock,
ExternalLink,
ChevronDown,
Coffee,
Landmark,
Loader2,
MoveHorizontal,
Plus,
RefreshCw,
Route,
Search,
Shuffle,
Star,
Trees,
WifiOff,
X,
type LucideIcon,
} from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../../i18n';
@@ -18,11 +22,12 @@ import { useSettingsStore } from '../../../store/settingsStore';
import { useTourPoiStore } from '../../../store/tourPoiStore';
import { formatDistance } from '../../../utils/units';
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
import { PopoverItem } from '../../Packing/PackingPopover';
import { WhiteBadge } from '../../Planner/planParts';
import { PillSelect, Segmented } from '../../shared/dialogParts';
import { PopoverListPanel, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { usePopoverList } from '../../shared/usePopoverList';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
import { RailCard, TourNotice } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { usePoiInsertion, type InsertionOutcome } from './usePoiInsertion';
@@ -33,28 +38,127 @@ type Unit = Parameters<typeof formatDistance>[1];
const km = (metres: number) => metres / 1000;
/** A category to search for, as a chip: its icon in its map colour, ticked or not. */
function CategoryChip({ category, on, onToggle }: { category: TourPoiCategory; on: boolean; onToggle: () => void }) {
/**
* What is searched for, in one line: the chosen categories' icons and names,
* with every category the tour type offers in a list that stays open while
* they are ticked. A theme of a city walk sets them all at once above it.
*/
function CategoryPicker({ search }: { search: TourPoiSearch }) {
const { t } = useTranslation();
const look = TOUR_POI_LOOKS[category];
const list = usePopoverList(0, { estimatedHeight: 320 });
const { open, toggle, triggerRef, listId } = list;
const chosen = search.offered.filter((category) => search.categories.includes(category));
const names = chosen.map((category) => t(TOUR_POI_LOOKS[category].labelKey));
const summary =
names.length === 0
? t('tours.poi.categoriesNone')
: names.length <= 2
? names.join(', ')
: t('tours.poi.categoriesMore', { first: names.slice(0, 2).join(', '), more: names.length - 2 });
return (
<button
type="button"
onClick={onToggle}
aria-pressed={on}
data-testid={`tour-poi-category-${category}`}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-inset ring-edge-faint' : 'bg-surface-tertiary text-content-muted hover:text-content'}`}
style={fs(11.5, 'body')}
>
<look.Icon size={12} strokeWidth={2.2} style={{ color: on ? look.color : undefined }} />
{t(look.labelKey)}
</button>
<>
<button
ref={triggerRef}
type="button"
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
data-testid="tour-poi-categories"
className="flex w-full min-w-0 items-center gap-2 rounded-[10px] border border-edge bg-surface-input px-2.5 py-1.5 text-start hover:border-content-faint"
>
<span className="sr-only">{t('tours.poi.categories')}: </span>
<span className="flex flex-none" aria-hidden="true">
{chosen.slice(0, 3).map((category, index) => {
const look = TOUR_POI_LOOKS[category];
return (
<span
key={category}
className={`grid h-5 w-5 place-items-center rounded-full bg-surface-card ring-2 ring-[color:var(--bg-card)] ${index > 0 ? '-ms-1' : ''}`}
style={{ color: look.color }}
>
<look.Icon size={11} strokeWidth={2.2} />
</span>
);
})}
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12, 'body')}>
{summary}
</span>
<ChevronDown
size={14}
strokeWidth={2.2}
aria-hidden="true"
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
<PopoverListPanel list={list} label={t('tours.poi.categories')} className="min-w-[240px]">
{search.offered.map((category) => {
const look = TOUR_POI_LOOKS[category];
return (
<PopoverItem
key={category}
icon={<look.Icon size={13} strokeWidth={2.2} style={{ color: look.color }} />}
label={t(look.labelKey)}
active={search.categories.includes(category)}
onClick={() => search.toggleCategory(category)}
/>
);
})}
</PopoverListPanel>
</>
);
}
/** What the search answered beyond its places: out of date, offline, failed, partly searched, cut, or empty. */
const THEME_ICONS: Record<WalkTheme, LucideIcon> = { culture: Landmark, parks: Trees, food: Coffee, mixed: Shuffle };
/**
* CW-05: a city walk's four themes as a grid of two by two, each naming
* itself in full; the one the categories below match is on.
*/
function ThemeGrid({
themes,
active,
onChoose,
}: {
themes: readonly WalkTheme[];
active: WalkTheme | null;
onChoose: (theme: WalkTheme) => void;
}) {
const { t } = useTranslation();
return (
<div className="grid grid-cols-2 gap-1.5" role="group" aria-label={t('tours.walk.themes')}>
{themes.map((theme) => {
const Icon = THEME_ICONS[theme];
const on = theme === active;
return (
<button
key={theme}
type="button"
onClick={() => onChoose(theme)}
aria-pressed={on}
title={t(`tours.walk.themeHint.${theme}`)}
data-testid={`tour-poi-theme-${theme}`}
className={`flex min-w-0 items-center gap-2 rounded-[10px] px-2.5 py-2 text-start font-medium leading-tight transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-inset ring-edge-faint' : 'bg-surface-tertiary text-content-muted hover:text-content'}`}
style={fs(11.5, 'body')}
>
<Icon size={13} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
<span className="min-w-0">{t(`tours.walk.theme.${theme}`)}</span>
</button>
);
})}
</div>
);
}
/**
* What the search answered beyond its places: offline, failed, kinds that
* could not be searched, partly searched, cut, or empty. That the answer is
* out of date the search button says, as it is what to press then. Nothing
* found is said only when every kind was searched.
*/
function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit }) {
const { t } = useTranslation();
const { status, answer, outdated } = search;
const { status, answer } = search;
if (status === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.poi.offline')}</TourNotice>;
if (status === 'error')
@@ -64,21 +168,21 @@ function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit })
</TourNotice>
);
if (!answer) return null;
// Out of date is no fault, the route was just changed (often by adding a place from here): said plainly.
const unavailable = answer.unavailable.map((category) => t(TOUR_POI_LOOKS[category].labelKey)).join(', ');
const notes = [
unavailable && t('tours.poi.unavailable', { kinds: unavailable }),
answer.partial && t('tours.poi.partial'),
answer.capped && t('tours.poi.capped'),
answer.truncated && t('tours.poi.truncated'),
].filter((note): note is string => !!note);
return (
<>
{outdated && <TourNotice icon={<RefreshCw size={13} strokeWidth={2} />}>{t('tours.poi.outdated')}</TourNotice>}
{notes.map((note) => (
<TourNotice key={note} tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{note}
</TourNotice>
))}
{answer.pois.length === 0 && (
{answer.pois.length === 0 && answer.unavailable.length === 0 && (
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('tours.poi.empty', { distance: formatDistance(km(search.corridorM), unit) })}
</p>
@@ -87,76 +191,12 @@ function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit })
);
}
/** The point a place would follow, picked from the tour's points, with why one cannot take it. */
function AfterPicker({
planner,
poi,
insertion,
}: {
planner: TourPlannerController;
poi: TourPoi;
insertion: ReturnType<typeof usePoiInsertion>;
}) {
const { t } = useTranslation();
const names = useWaypointPlaceNames(planner.waypoints);
const afterId = insertion.afterIdFor(poi);
const options = planner.waypoints.map((point, index) => ({
value: point.id,
label: `${index + 1}. ${point.shaping ? t('tours.planner.shapingPoint') : (names[point.id] ?? `${point.lat.toFixed(4)}, ${point.lng.toFixed(4)}`)}`,
}));
const block = afterId ? insertion.blockOf(afterId) : 'none';
const locked = !planner.canEdit || planner.isSaving;
return (
<div className="flex flex-col gap-2 rounded-[10px] bg-surface-secondary p-2.5" data-testid="tour-poi-insert">
<div className="flex flex-wrap items-center gap-2">
<span className="text-content-muted" style={fs(11.5, 'body')}>
{t('tours.poi.addAfter')}
</span>
<PillSelect
quiet
label={t('tours.poi.addAfter')}
value={afterId ?? ''}
options={options}
onChange={(value) => insertion.chooseAfter(poi, value)}
/>
</div>
{block && (
<p className="m-0 text-warning" style={fs(11, 'body')}>
{t(`tours.poi.blocked.${block}`)}
</p>
)}
<button
type="button"
disabled={!!block || locked}
onClick={() => insertion.insert(poi)}
data-testid="tour-poi-add"
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
style={fs(12, 'body')}
>
<Plus size={14} strokeWidth={2.2} />
{t('tours.poi.add')}
</button>
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.poi.notAPlace')}
</p>
</div>
);
}
/** One place found: its kind, name and where it lies; opened, what the source says and the way to take it in. */
function PoiRow({
poi,
selected,
planner,
insertion,
unit,
}: {
poi: TourPoi;
selected: boolean;
planner: TourPlannerController;
insertion: ReturnType<typeof usePoiInsertion>;
unit: Unit;
}) {
/**
* One place found, in one line: its kind's icon in its map colour, its name,
* and how far it lies from the route. A click opens its card over the map,
* where the rest is said and the place is taken in as a stop.
*/
function PoiRow({ poi, selected, unit }: { poi: TourPoi; selected: boolean; unit: Unit }) {
const { t } = useTranslation();
const select = useTourPoiStore((state) => state.select);
const look = TOUR_POI_LOOKS[poi.category];
@@ -164,76 +204,45 @@ function PoiRow({
useEffect(() => {
if (selected) rowRef.current?.scrollIntoView?.({ block: 'nearest' });
}, [selected]);
const distance = formatDistance(km(poi.distance_m), unit);
return (
<li
ref={rowRef}
className={`rounded-[10px] ${selected ? 'bg-surface-selected' : ''}`}
data-testid={`tour-poi-${poi.id}`}
>
<li ref={rowRef} data-testid={`tour-poi-${poi.id}`}>
<button
type="button"
onClick={() => select(selected ? null : poi.id)}
aria-expanded={selected}
className="flex w-full min-w-0 items-start gap-2.5 rounded-[10px] px-2 py-2 text-start transition-colors hover:bg-surface-hover"
onClick={() => select(poi.id)}
aria-pressed={selected}
className={`flex w-full min-w-0 items-center gap-2 rounded-[8px] px-1.5 py-1 text-start transition-colors hover:bg-surface-hover ${selected ? 'bg-surface-selected' : ''}`}
>
{/* The category's own map colour, as its pin on the map has it: a light wash, the icon in full. */}
<span
className="grid h-7 w-7 flex-none place-items-center rounded-full"
className="grid h-6 w-6 flex-none place-items-center rounded-full"
style={{ background: `color-mix(in srgb, ${look.color} 16%, transparent)`, color: look.color }}
>
<look.Icon size={14} strokeWidth={2.2} />
<look.Icon size={12} strokeWidth={2.2} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="min-w-0 truncate font-semibold text-content" style={fs(12.5, 'body')}>
{poi.name || t(look.labelKey)}
</span>
<span className="flex flex-wrap gap-1.5">
<WhiteBadge icon={<MoveHorizontal size={10} strokeWidth={2.2} className="text-content-faint" />}>
{t('tours.poi.fromRoute', { distance: formatDistance(km(poi.distance_m), unit) })}
</WhiteBadge>
<WhiteBadge icon={<Route size={10} strokeWidth={2.2} className="text-content-faint" />}>
{t('tours.poi.along', { distance: formatDistance(km(poi.along_m), unit) })}
</WhiteBadge>
{/* One of the best known places along the route: a star before its name. */}
{poi.highlight && (
<span
role="img"
aria-label={t('tours.poi.highlight')}
title={t('tours.poi.highlightHint')}
className="flex-none text-warning"
data-testid="tour-poi-highlight"
>
<Star size={12} strokeWidth={2.2} fill="currentColor" aria-hidden="true" />
</span>
)}
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12, 'body')}>
{poi.name || t(look.labelKey)}
</span>
<span
className="flex-none tabular-nums text-content-faint"
style={fs(11, 'body')}
title={t('tours.poi.fromRoute', { distance })}
>
{distance}
</span>
</button>
{selected && (
<div className="flex flex-col gap-2 px-2 pb-2.5">
<p className="m-0 flex flex-wrap gap-x-3 text-content-faint" style={fs(10.5, 'body')}>
<span>{t(look.labelKey)}</span>
<span>{t(poi.source === 'trek-places' ? 'tours.poi.source.trek' : 'tours.poi.source.osm')}</span>
</p>
{poi.address && (
<p className="m-0 text-content-secondary" style={fs(11.5, 'body')}>
{poi.address}
</p>
)}
{poi.opening_hours && (
<p className="m-0 flex items-start gap-1.5 text-content-secondary" style={fs(11.5, 'body')}>
<Clock size={12} strokeWidth={2.2} className="mt-0.5 flex-none text-content-faint" />
<span>
{poi.opening_hours}
<span className="block text-content-faint" style={fs(10.5, 'body')}>
{t('tours.poi.hoursHint')}
</span>
</span>
</p>
)}
{poi.website && (
<a
href={poi.website}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 self-start text-content-secondary underline-offset-2 hover:underline"
style={fs(11.5, 'body')}
>
<ExternalLink size={12} strokeWidth={2.2} />
{t('tours.poi.website')}
</a>
)}
<AfterPicker planner={planner} poi={poi} insertion={insertion} />
</div>
)}
</li>
);
}
@@ -253,24 +262,30 @@ function OutcomeNotice({ outcome, onDismiss, unit }: { outcome: InsertionOutcome
)
}
>
<span className="flex flex-col gap-1.5">
<span>{t(`tours.poi.outcome.${outcome.kind}`, { name: outcome.name })}</span>
{outcome.kind === 'measured' && (
<span className="flex flex-wrap gap-1.5">
<WhiteBadge>{signed(formatDistance(km(Math.abs(outcome.extraM)), unit), outcome.extraM < 0)}</WhiteBadge>
{outcome.extraMinutes != null && (
<span className="flex items-start gap-2">
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<span>{t(`tours.poi.outcome.${outcome.kind}`, { name: outcome.name })}</span>
{outcome.kind === 'measured' && (
<>
<WhiteBadge>
{signed(formatPlannedTourDuration(Math.abs(outcome.extraMinutes)), outcome.extraMinutes < 0)}
{signed(formatDistance(km(Math.abs(outcome.extraM)), unit), outcome.extraM < 0)}
</WhiteBadge>
)}
</span>
)}
<span className="flex flex-wrap items-center gap-2 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.poi.undoHint')}
<button type="button" onClick={onDismiss} className="font-semibold underline underline-offset-2">
{t('common.close')}
</button>
{outcome.extraMinutes != null && (
<WhiteBadge>
{signed(formatPlannedTourDuration(Math.abs(outcome.extraMinutes)), outcome.extraMinutes < 0)}
</WhiteBadge>
)}
</>
)}
</span>
<button
type="button"
onClick={onDismiss}
aria-label={t('common.close')}
className="flex-none rounded p-0.5 text-content-faint hover:text-content"
>
<X size={12} strokeWidth={2.4} aria-hidden="true" />
</button>
</span>
</TourNotice>
</div>
@@ -304,19 +319,12 @@ export function TourPoiSearchCard({ planner }: { planner: TourPlannerController
}
>
<div className="flex flex-col gap-3" data-testid="tour-poi-card">
<p className="m-0 text-content-faint" style={fs(11, 'body')}>
{t('tours.poi.intro')}
</p>
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t('tours.poi.categories')}>
{search.offered.map((category) => (
<CategoryChip
key={category}
category={category}
on={search.categories.includes(category)}
onToggle={() => search.toggleCategory(category)}
/>
))}
</div>
{search.themes.length > 0 && (
<div data-testid="tour-poi-themes">
<ThemeGrid themes={search.themes} active={search.theme} onChoose={search.chooseTheme} />
</div>
)}
<CategoryPicker search={search} />
<div className="flex flex-col gap-1.5">
<span className="text-content-muted" style={fs(11, 'body')}>
{t('tours.poi.within')}
@@ -336,8 +344,10 @@ export function TourPoiSearchCard({ planner }: { planner: TourPlannerController
type="button"
onClick={() => void search.search()}
disabled={!search.canSearch}
title={search.outdated ? t('tours.poi.outdated') : undefined}
data-testid="tour-poi-search"
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-[10px] bg-surface-tertiary font-semibold text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
data-outdated={search.outdated || undefined}
className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-[10px] font-semibold transition-colors disabled:cursor-default disabled:opacity-50 ${search.outdated ? 'bg-accent text-accent-text hover:opacity-90' : 'bg-surface-tertiary text-content hover:bg-surface-hover'}`}
style={fs(12.5, 'body')}
>
{search.status === 'loading' ? (
@@ -354,16 +364,12 @@ export function TourPoiSearchCard({ planner }: { planner: TourPlannerController
{insertion.outcome && <OutcomeNotice outcome={insertion.outcome} onDismiss={insertion.dismiss} unit={unit} />}
<SearchNotices search={search} unit={unit} />
{pois.length > 0 && (
<ul className={`m-0 flex list-none flex-col gap-0.5 p-0 ${search.status === 'loading' ? 'opacity-60' : ''}`}>
<ul
className={`m-0 flex max-h-[320px] list-none flex-col gap-px overflow-y-auto overscroll-contain p-0 ${search.status === 'loading' ? 'opacity-60' : ''}`}
data-testid="tour-poi-list"
>
{pois.map((poi) => (
<PoiRow
key={poi.id}
poi={poi}
selected={selected?.id === poi.id}
planner={planner}
insertion={insertion}
unit={unit}
/>
<PoiRow key={poi.id} poi={poi} selected={selected?.id === poi.id} unit={unit} />
))}
</ul>
)}
@@ -1,5 +1,5 @@
import { nearnessToLine } from '@trek/shared';
import { isClosedLoop, withPointAppended, type RoutePoint } from '../planner/tourRouteActions';
import { isClosedLoop, nearnessToLine } from '@trek/shared';
import { withPointAppended, type RoutePoint } from '../planner/tourRouteActions';
type Point = RoutePoint & { role: string; shaping?: boolean };
@@ -2,16 +2,19 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourPoi, TourPoiSearchResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourPoiSearchRepo } from '../../../repo/tourPoiSearchRepo';
import { useTourPoiStore } from '../../../store/tourPoiStore';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { TourPlannerController } from '../planner/useTourPlanner';
import TourPoiOverlay from './TourPoiCard';
import { TourPoiSearchCard } from './TourPoiSearchCard';
import { usePoiInsertion, type PoiInsertionPlanner } from './usePoiInsertion';
import { useTourPoiMarkers } from './useTourPoiMarkers';
import { useTourPoiSearch } from './useTourPoiSearch';
// FE-TOUR-POI-001 to FE-TOUR-POI-008
// FE-TOUR-POI-001 to FE-TOUR-POI-018
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
@@ -39,7 +42,13 @@ const SPRING: TourPoi = {
distance_m: 80,
along_m: 1650,
};
const ANSWER: TourPoiSearchResponse = { pois: [SPRING], truncated: false, partial: false, capped: false };
const ANSWER: TourPoiSearchResponse = {
pois: [SPRING],
truncated: false,
partial: false,
capped: false,
unavailable: [],
};
let search: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
@@ -186,21 +195,246 @@ describe('usePoiInsertion (PLN-03)', () => {
});
});
/** The list in the side panel and the chosen place's card over the map, as the planner shows them. */
function renderAlong(planner: TourPlannerController) {
return render(
<>
<TourPoiSearchCard planner={planner} />
<TourPoiOverlay planner={planner} insetEnd={0} bottom={0} />
</>
);
}
describe('TourPoiSearchCard', () => {
it('FE-TOUR-POI-008: lists what was found with where it lies; opening a place offers to add it after a point', async () => {
it('FE-TOUR-POI-008: lists what was found in one line each; a place opens its card over the map, which adds it after a point', async () => {
const planner = {
...searchPlanner(),
...insertionPlanner(),
} as unknown as TourPlannerController;
render(<TourPoiSearchCard planner={planner} />);
renderAlong(planner);
fireEvent.click(screen.getByTestId('tour-poi-search'));
const row = await screen.findByTestId('tour-poi-node:1');
expect(row).toHaveTextContent('Spring');
expect(row).toHaveTextContent('80 m from the route');
expect(row).toHaveTextContent('80 m');
expect(within(row).queryByTestId('tour-poi-add')).not.toBeInTheDocument();
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
fireEvent.click(within(row).getByRole('button', { name: /Spring/ }));
expect(within(row).getByText('Mo-Su 08:00-18:00')).toBeInTheDocument();
expect(within(row).getByText('As the source has them; check before you go.')).toBeInTheDocument();
fireEvent.click(within(row).getByTestId('tour-poi-add'));
const card = screen.getByTestId('tour-poi-overlay');
expect(within(row).getByRole('button', { name: /Spring/ })).toHaveAttribute('aria-pressed', 'true');
expect(card).toHaveTextContent('80 m from the route');
expect(within(card).getByText('Mo-Su 08:00-18:00')).toBeInTheDocument();
expect(within(card).getByText('Mo-Su 08:00-18:00')).toHaveAttribute(
'title',
'As the source has them; check before you go.'
);
fireEvent.click(within(card).getByTestId('tour-poi-add'));
await waitFor(() => expect(planner.editWaypoints).toHaveBeenCalledTimes(1));
// The card closes once the place is in. What it did is said in the list once the route has it (FE-TOUR-POI-006).
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
});
it('FE-TOUR-POI-016: the card over the map closes on its button and on Escape, and a pin on the map opens it too', async () => {
const planner = { ...searchPlanner(), ...insertionPlanner() } as unknown as TourPlannerController;
renderAlong(planner);
fireEvent.click(screen.getByTestId('tour-poi-search'));
await screen.findByTestId('tour-poi-node:1');
const markers = renderHook(() => useTourPoiMarkers(), { wrapper: TranslationProvider });
// The one place found, picked as a click on its pin does.
for (const poi of markers.result.current.pois) act(() => markers.result.current.onPoiClick(poi));
expect(screen.getByTestId('tour-poi-overlay')).toHaveTextContent('Spring');
fireEvent.click(within(screen.getByTestId('tour-poi-overlay')).getByRole('button', { name: 'Close' }));
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
expect(screen.getByTestId('tour-poi-overlay')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
});
it('FE-TOUR-POI-009: names the kinds that could not be searched, and does not claim nothing was found', async () => {
search.mockResolvedValue({ ...ANSWER, pois: [], unavailable: ['viewpoint', 'shelter'] });
render(<TourPoiSearchCard planner={searchPlanner()} />);
fireEvent.click(screen.getByTestId('tour-poi-search'));
expect(
await screen.findByText('Could not be searched right now: Viewpoints, Huts and shelters. Try again in a moment.')
).toBeInTheDocument();
expect(screen.queryByText(/Nothing found/)).not.toBeInTheDocument();
});
it('FE-TOUR-POI-010: an unnamed place is named by its kind in the list, on the map and once taken in', async () => {
const tap: TourPoi = { ...SPRING, id: 'node:2', name: '' };
search.mockResolvedValue({ ...ANSWER, pois: [tap] });
const planner = { ...searchPlanner(), ...insertionPlanner() } as unknown as TourPlannerController;
render(<TourPoiSearchCard planner={planner} />);
fireEvent.click(screen.getByTestId('tour-poi-search'));
const row = await screen.findByTestId('tour-poi-node:2');
expect(within(row).getByRole('button', { name: /Drinking water/ })).toBeInTheDocument();
const markers = renderHook(() => useTourPoiMarkers(), { wrapper: TranslationProvider });
expect(markers.result.current.pois.map((poi) => poi.name)).toEqual(['Drinking water']);
let taking = insertionPlanner();
const { result, rerender } = renderHook(() => usePoiInsertion(taking), { wrapper: TranslationProvider });
act(() => {
result.current.insert(tap);
});
const added = vi.mocked(taking.setSelectedWaypointId).mock.calls[0]?.[0] as string;
const withPoint = [PA, PB, { id: added, lat: tap.lat, lng: tap.lng, role: 'via' as const }, PC];
taking = insertionPlanner({ waypoints: withPoint, routeCurrent: false, status: 'routing' });
rerender();
expect(result.current.outcome).toEqual({ kind: 'measuring', name: 'Drinking water' });
});
});
describe('highlights along a tour', () => {
const SQUARE: TourPoi = {
...SPRING,
id: 'way:1',
name: 'Piazza San Marco',
category: 'sights',
kind: 'place=square',
opening_hours: null,
website: null,
highlight: true,
};
it('FE-TOUR-POI-017: a highlight carries a star before its name in the list, a place that is none carries none', async () => {
search.mockResolvedValue({ ...ANSWER, pois: [SQUARE, SPRING] });
render(<TourPoiSearchCard planner={searchPlanner()} />);
fireEvent.click(screen.getByTestId('tour-poi-search'));
const square = await screen.findByTestId('tour-poi-way:1');
const star = within(square).getByTestId('tour-poi-highlight');
expect(star).toHaveAttribute('aria-label', 'Highlight');
expect(star).toHaveAttribute('title', 'One of the best-known places here');
expect(within(square).getByRole('button', { name: /Highlight.*Piazza San Marco/ })).toBeInTheDocument();
expect(within(screen.getByTestId('tour-poi-node:1')).queryByTestId('tour-poi-highlight')).not.toBeInTheDocument();
});
it('FE-TOUR-POI-018: the card over the map says Highlight beside the kind, and only for a highlight', async () => {
search.mockResolvedValue({ ...ANSWER, pois: [SQUARE, SPRING] });
const planner = { ...searchPlanner(), ...insertionPlanner() } as unknown as TourPlannerController;
renderAlong(planner);
fireEvent.click(screen.getByTestId('tour-poi-search'));
fireEvent.click(within(await screen.findByTestId('tour-poi-way:1')).getByRole('button', { name: /Piazza/ }));
const badge = within(screen.getByTestId('tour-poi-overlay')).getByTestId('tour-poi-highlight-badge');
expect(badge).toHaveTextContent('Highlight');
expect(badge).toHaveAttribute('title', 'One of the best-known places here');
expect(screen.getByTestId('tour-poi-overlay')).toHaveTextContent('Sights');
fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
expect(screen.queryByTestId('tour-poi-highlight-badge')).not.toBeInTheDocument();
});
});
describe('a city walk along its places (CW-05)', () => {
const CAFE: TourPoi = {
...SPRING,
id: 'node:3',
name: 'Cafe Luitpold',
category: 'cafe',
kind: 'amenity=cafe',
opening_hours: null,
};
const walkPlanner = () =>
({ ...searchPlanner({ tourType: 'walk' }), ...insertionPlanner() }) as unknown as TourPlannerController;
const themeButton = (theme: string) => screen.getByTestId(`tour-poi-theme-${theme}`);
it('FE-TOUR-POI-011: a walk offers four themes; one sets its categories at once, a change of them leaves it', async () => {
const planner = walkPlanner();
render(<TourPoiSearchCard planner={planner} />);
const themes = screen.getByTestId('tour-poi-themes');
expect(
within(themes)
.getAllByRole('button')
.map((button) => button.textContent)
).toEqual(['Culture and history', 'Parks and views', 'Food and local life', 'A bit of everything']);
// The walk starts with its first three categories, which no theme is.
for (const theme of ['culture', 'parks', 'food', 'mixed'])
expect(themeButton(theme)).toHaveAttribute('aria-pressed', 'false');
// What a theme looks for is said on the button, not in a line of its own.
expect(themeButton('food')).toHaveAttribute('title', 'Coffee shops, places to eat and markets along the route.');
fireEvent.click(themeButton('food'));
expect(themeButton('food')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('tour-poi-categories')).toHaveTextContent('Coffee shops, Places to eat +1');
fireEvent.click(screen.getByTestId('tour-poi-search'));
await waitFor(() =>
expect(search).toHaveBeenCalledWith(
4,
expect.objectContaining({ tour_type: 'walk', categories: ['cafe', 'restaurant', 'market'] }),
expect.any(AbortSignal)
)
);
fireEvent.click(screen.getByTestId('tour-poi-categories'));
fireEvent.click(screen.getByRole('button', { name: 'Markets' }));
expect(themeButton('food')).toHaveAttribute('aria-pressed', 'false');
});
it('FE-TOUR-POI-012: the category field names two categories, counts the rest, and says when none is chosen', () => {
render(<TourPoiSearchCard planner={walkPlanner()} />);
const field = screen.getByTestId('tour-poi-categories');
expect(field).toHaveTextContent('Sights, Museums and galleries +1');
expect(field).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(field);
expect(field).toHaveAttribute('aria-expanded', 'true');
const list = screen.getByRole('group', { name: 'What to look for' });
fireEvent.click(within(list).getByRole('button', { name: 'Parks and gardens' }));
expect(field).toHaveTextContent('Sights, Museums and galleries');
expect(field).not.toHaveTextContent('+1');
fireEvent.click(within(list).getByRole('button', { name: 'Sights' }));
fireEvent.click(within(list).getByRole('button', { name: 'Museums and galleries' }));
expect(field).toHaveTextContent('No category chosen');
expect(screen.getByTestId('tour-poi-search')).toBeDisabled();
});
it('FE-TOUR-POI-013: a place found for a theme says which theme suggested it', async () => {
search.mockResolvedValue({ ...ANSWER, pois: [CAFE, SPRING] });
renderAlong(walkPlanner());
fireEvent.click(themeButton('food'));
fireEvent.click(screen.getByTestId('tour-poi-search'));
const cafe = await screen.findByTestId('tour-poi-node:3');
fireEvent.click(within(cafe).getByRole('button', { name: /Cafe Luitpold/ }));
expect(
within(screen.getByTestId('tour-poi-overlay')).getByText('Suggested for Food and local life')
).toBeInTheDocument();
fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
expect(screen.queryByText('Suggested for Food and local life')).not.toBeInTheDocument();
});
it('FE-TOUR-POI-014: a hike has no themes', () => {
render(<TourPoiSearchCard planner={searchPlanner()} />);
expect(screen.queryByTestId('tour-poi-themes')).not.toBeInTheDocument();
expect(screen.getByTestId('tour-poi-categories')).toHaveTextContent('Viewpoints, Huts and shelters +1');
});
it('FE-TOUR-POI-015: a place taken in keeps its name and opening hours, and a place without them adds none', () => {
const planner = insertionPlanner();
const { result } = renderHook(() => usePoiInsertion(planner), { wrapper: TranslationProvider });
act(() => {
result.current.insert(SPRING);
});
act(() => {
result.current.insert({ ...CAFE, id: 'node:4', name: '', along_m: 400 });
});
const added = vi
.mocked(planner.editWaypoints)
.mock.calls.map(
([update]) =>
(update as (points: TourPlannerWaypoint[]) => TourPlannerWaypoint[])(POINTS).find(
(point) => !POINTS.includes(point)
) as TourPlannerWaypoint
);
expect(added[0]).toMatchObject({
name: 'Spring',
openingHours: 'Mo-Su 08:00-18:00',
poiCategory: 'drinking_water',
});
// PLN-03: the kind the place was found as goes with it, so its row carries the kind's icon.
expect(added[1]).toMatchObject({ poiCategory: 'cafe' });
expect(added[1]).not.toHaveProperty('name');
expect(added[1]).not.toHaveProperty('openingHours');
});
});
@@ -1,7 +1,9 @@
import type { TourPoi } from '@trek/shared';
import { useCallback, useState } from 'react';
import { useTourPoiStore } from '../../../store/tourPoiStore';
import { useTranslation } from '../../../i18n';
import { useTourPoiStore, type InsertedTourPoi } from '../../../store/tourPoiStore';
import { randomId } from '../../../utils/randomId';
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { TourPlannerController, TourPlannerStatus } from '../planner/useTourPlanner';
import { defaultAfterId, insertionBlock, withPointAfter } from './poiInsertion';
@@ -14,15 +16,6 @@ interface Measures {
minutes: number | null;
}
/** A place taken in: which point it became, and the route's measures just before. */
interface Inserted {
pointId: string;
name: string;
before: Measures | null;
/** How many points the tour had once the place was in; another edit after it ends the comparison. */
pointCount: number;
}
export type InsertionOutcome =
| { kind: 'measuring'; name: string }
| { kind: 'measured'; name: string; extraM: number; extraMinutes: number | null }
@@ -57,9 +50,12 @@ function measuresOf(planner: PoiInsertionPlanner): Measures | null {
* hand or a fixed line is never cut: the point after which it would go says why.
*/
export function usePoiInsertion(planner: PoiInsertionPlanner) {
const { t } = useTranslation();
const select = useTourPoiStore((state) => state.select);
const [afterChoice, setAfterChoice] = useState<{ poiId: string; afterId: string } | null>(null);
const [inserted, setInserted] = useState<Inserted | null>(null);
// Kept in the store: the place is taken in from its card over the map, and the list says what it did.
const inserted = useTourPoiStore((state) => state.inserted);
const setInserted = useTourPoiStore((state) => state.setInserted);
const afterIdFor = useCallback(
(poi: TourPoi) =>
@@ -74,20 +70,32 @@ export function usePoiInsertion(planner: PoiInsertionPlanner) {
(poi: TourPoi) => {
const afterId = afterIdFor(poi);
if (!afterId || !planner.canEdit || planner.isSaving) return false;
const point: TourPlannerWaypoint = { id: randomId(), lat: poi.lat, lng: poi.lng, role: 'via' };
// The place keeps its name, and on a city walk its opening hours to check the arrival against (CW-04).
const point: TourPlannerWaypoint = {
id: randomId(),
lat: poi.lat,
lng: poi.lng,
role: 'via',
...(poi.name ? { name: poi.name } : {}),
...(poi.opening_hours ? { openingHours: poi.opening_hours } : {}),
// PLN-03: its row carries the kind's icon, as the map drew the place.
poiCategory: poi.category,
};
const next = withPointAfter(planner.waypoints, afterId, point);
if (!next) return false;
const before = measuresOf(planner);
planner.editWaypoints((current) => withPointAfter(current, afterId, point) ?? current);
planner.setSelectedWaypointId(point.id);
setInserted({ pointId: point.id, name: poi.name, before, pointCount: next.length });
// An unnamed place (a tap, a bench) is told by its kind, as the list names it.
const name = poi.name || t(TOUR_POI_LOOKS[poi.category].labelKey);
setInserted({ pointId: point.id, name, before, pointCount: next.length });
select(null);
return true;
},
[afterIdFor, planner, select]
[afterIdFor, planner, select, setInserted, t]
);
const dismiss = useCallback(() => setInserted(null), []);
const dismiss = useCallback(() => setInserted(null), [setInserted]);
return {
afterIdFor,
@@ -104,7 +112,7 @@ export function usePoiInsertion(planner: PoiInsertionPlanner) {
* now: nothing once the point is gone again (undone or removed) or the tour
* was edited further, so the figure never describes another route.
*/
function outcomeOf(inserted: Inserted | null, planner: PoiInsertionPlanner): InsertionOutcome | null {
function outcomeOf(inserted: InsertedTourPoi | null, planner: PoiInsertionPlanner): InsertionOutcome | null {
if (!inserted) return null;
const stillThere = planner.waypoints.some((point) => point.id === inserted.pointId);
if (!stillThere || planner.waypoints.length !== inserted.pointCount) return null;
@@ -1,19 +1,22 @@
import { useMemo } from 'react';
import { useTranslation } from '../../../i18n';
import { useTourPoiStore } from '../../../store/tourPoiStore';
import type { Poi } from '../../Map/poiCategories';
import { TOUR_POI_LOOKS, type Poi } from '../../Map/poiCategories';
/**
* PLN-02: the places found along the tour as the planner map draws POIs, each
* in its category's look, and a click on one that picks it in the card.
* in its category's look, and a click on one that picks it in the card. An
* unnamed place (a tap, a bench) is named by its kind, as the list names it.
*/
export function useTourPoiMarkers(): { pois: Poi[]; onPoiClick: (poi: Poi) => void } {
const { t } = useTranslation();
const found = useTourPoiStore((state) => state.pois);
const select = useTourPoiStore((state) => state.select);
const pois = useMemo(
() =>
found.map((poi): Poi => ({
osm_id: poi.id,
name: poi.name,
name: poi.name || t(TOUR_POI_LOOKS[poi.category].labelKey),
lat: poi.lat,
lng: poi.lng,
category: poi.category,
@@ -25,7 +28,7 @@ export function useTourPoiMarkers(): { pois: Poi[]; onPoiClick: (poi: Poi) => vo
cuisine: null,
source: poi.source,
})),
[found]
[found, t]
);
return { pois, onPoiClick: (poi: Poi) => select(poi.osm_id) };
}
@@ -2,10 +2,13 @@ import {
TOUR_POI_CATEGORIES_BY_TYPE,
TOUR_POI_MAX_CATEGORIES,
TOUR_POI_MAX_LINE_POINTS,
WALK_THEME_KEYS,
WALK_THEMES,
type PlannableTourType,
type TourPoi,
type TourPoiCategory,
type TourPoiSearchResponse,
type WalkTheme,
} from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
@@ -29,6 +32,10 @@ function thinned(line: Array<[number, number]>, most: number): Array<[number, nu
);
}
function sameSet(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((item) => b.includes(item));
}
/** What a search was asked with, so a later change of the route or the choices shows it is out of date. */
function keyOf(route: Array<[number, number]> | null, type: string, categories: readonly string[], corridor: number) {
const ends = route && route.length > 1 ? [route[0], route[route.length - 1], route.length] : [];
@@ -64,6 +71,13 @@ export function useTourPoiSearch(planner: TourPoiPlanner) {
const currentKey = keyOf(route, type, categories, corridorM);
const canSearch = !!route && route.length > 1 && categories.length > 0 && loadingKey === null;
// CW-05: a city walk's theme is a set of its categories; the one the categories match exactly is the theme on.
const theme = type === 'walk' ? (WALK_THEME_KEYS.find((key) => sameSet(WALK_THEMES[key], categories)) ?? null) : null;
const chooseTheme = useCallback(
(key: WalkTheme) => setChosen((current) => ({ ...current, walk: [...WALK_THEMES[key]] })),
[]
);
const toggleCategory = useCallback(
(category: TourPoiCategory) =>
setChosen((current) => {
@@ -98,7 +112,7 @@ export function useTourPoiSearch(planner: TourPoiPlanner) {
controller.signal
);
setAnswer({ key, status: 'ready', data });
publish(data.pois);
publish(data.pois, theme);
} catch (err: unknown) {
if (controller.signal.aborted) return;
setAnswer({ key, status: err instanceof OnlineOnlyError ? 'offline' : 'error' });
@@ -109,7 +123,7 @@ export function useTourPoiSearch(planner: TourPoiPlanner) {
setLoadingKey(null);
}
}
}, [categories, clear, corridorM, currentKey, locale, planner.tripId, publish, route, type]);
}, [categories, clear, corridorM, currentKey, locale, planner.tripId, publish, route, theme, type]);
// Leaving the planner, or a tour closed, takes the places off the map.
useEffect(
@@ -125,6 +139,10 @@ export function useTourPoiSearch(planner: TourPoiPlanner) {
offered: TOUR_POI_CATEGORIES_BY_TYPE[type],
categories,
toggleCategory,
/** CW-05: a city walk's themes; none for any other type. */
themes: type === 'walk' ? WALK_THEME_KEYS : [],
theme,
chooseTheme,
corridorM,
setCorridorM,
canSearch,
@@ -30,5 +30,6 @@ describe('movingTimeLabel', () => {
expect(movingTimeLabel({ tour_type: 'kayak' }, 'walking', t)).toBe('tours.paddle.timeEstimatedShort');
expect(movingTimeLabel({ tour_type: 'hike' }, 'walking', t)).toBe('walking');
expect(movingTimeLabel(undefined, 'walking', t)).toBe('walking');
expect(movingTimeLabel({ tour_type: 'walk' }, 'walking', t)).toBe('tours.walk.timeEstimatedShort');
});
});
+3 -1
View File
@@ -46,7 +46,8 @@ export function withoutHeights<
/**
* What the time on the move is called: walking for a hike (in the wording the
* place asks for), riding for a bike tour, paddling for a paddle tour.
* place asks for) and for a city walk (CW-02), riding for a bike tour,
* paddling for a paddle tour.
*/
export function movingTimeLabel(
tour: { tour_type?: string } | undefined,
@@ -54,5 +55,6 @@ export function movingTimeLabel(
t: (key: string) => string
): string {
if (tour?.tour_type === 'kayak') return t('tours.paddle.timeEstimatedShort');
if (tour?.tour_type === 'walk') return t('tours.walk.timeEstimatedShort');
return tour?.tour_type === 'bike' ? t('tours.bike.timeEstimatedShort') : hikeLabel;
}
+5 -3
View File
@@ -301,7 +301,9 @@ type FieldColumns = keyof typeof FIELD_COLUMNS
/**
* Labelled values in the booking-card look: a fixed number of columns where the
* caller wants one row per group, otherwise as many as fit.
* caller wants one row per group, otherwise as many as fit. A value too long for
* its column, such as a city walk's "at least" total on a phone, wraps rather
* than being cut, and the boxes of a row keep one height.
*/
export function TourFactFields({ fields, columns }: { fields: readonly TourMetricField[]; columns?: FieldColumns }) {
return (
@@ -309,7 +311,7 @@ export function TourFactFields({ fields, columns }: { fields: readonly TourMetri
className={`m-0 grid gap-2 ${columns ? FIELD_COLUMNS[columns] : 'grid-cols-[repeat(auto-fit,minmax(104px,1fr))]'}`}
>
{fields.map((field) => (
<div key={field.label} className="min-w-0">
<div key={field.label} className="flex min-w-0 flex-col">
<dt className="mb-[3px] flex min-w-0 items-center gap-0.5">
<Eyebrow className="relative min-w-0 truncate">
<FitLabel full={field.label} short={field.shortLabel} />
@@ -321,7 +323,7 @@ export function TourFactFields({ fields, columns }: { fields: readonly TourMetri
)}
</dt>
<dd
className={`${BOX} m-0 truncate px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`}
className={`${BOX} m-0 flex flex-1 flex-col justify-center break-words px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`}
style={fs(12.5, 'body')}
>
{field.value}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
import { filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, plannedTotalText, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
describe('tourPresentation', () => {
it('formats planned Tour totals using the shared compact duration formatter', () => {
@@ -10,14 +10,38 @@ describe('tourPresentation', () => {
it('calculates planned total from walking and breaks unless a manual override exists', () => {
expect(tourPlannedTimes({ duration: 80, planned_duration_minutes: null, break_additional_minutes: 25 })).toEqual({
walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 105, manuallyOverridden: false,
walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 105, manuallyOverridden: false, walk: null, atLeast: false,
})
expect(tourPlannedTimes({ duration: 80, planned_duration_minutes: 130, break_additional_minutes: 25 })).toEqual({
walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 130, manuallyOverridden: true,
walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 130, manuallyOverridden: true, walk: null, atLeast: false,
})
expect(tourPlannedTimes({ duration: null, planned_duration_minutes: null, break_additional_minutes: 25 }).plannedTotalMinutes).toBeNull()
})
it('CW-02: adds a city walk its stays and no breaks, and says the total is at least that while a stay is not entered', () => {
const walk = { tour_type: 'walk' as const, duration: 45, planned_duration_minutes: null, break_additional_minutes: 15 }
expect(tourPlannedTimes({ ...walk, station_count: 3, station_stay_minutes: 120, station_stays_unknown: 0 })).toEqual({
walkingMinutes: 45, breakMinutes: null, plannedTotalMinutes: 165, manuallyOverridden: false, atLeast: false,
walk: { walkingMinutes: 45, stayMinutes: 120, unknownStays: 0, stations: 3, totalMinutes: 165, partial: false },
})
const partial = tourPlannedTimes({ ...walk, station_count: 3, station_stay_minutes: 90, station_stays_unknown: 1 })
expect(partial).toMatchObject({ plannedTotalMinutes: 135, atLeast: true })
// A total entered by hand is that total, whatever the stays say.
expect(tourPlannedTimes({ ...walk, planned_duration_minutes: 200, station_count: 3, station_stay_minutes: 90, station_stays_unknown: 1 }))
.toMatchObject({ plannedTotalMinutes: 200, manuallyOverridden: true, atLeast: false })
// A list cached before the stays were listed: the walking time alone, and still no breaks.
expect(tourPlannedTimes(walk)).toMatchObject({ breakMinutes: null, plannedTotalMinutes: 45, walk: null, atLeast: false })
expect(tourPlannedTimes({ ...walk, station_stay_minutes: 30 }).walk).toMatchObject({ stations: 0, unknownStays: 0, totalMinutes: 75 })
})
it('CW-02: says a planned total as it is, at least that much, or as a badge with ≥', () => {
const t = (key: string, params?: Record<string, unknown>) => `${key}:${String(params?.time)}`
expect(plannedTotalText({ plannedTotalMinutes: null, atLeast: false }, t)).toBeNull()
expect(plannedTotalText({ plannedTotalMinutes: 165, atLeast: false }, t)).toBe('2 h 45 min')
expect(plannedTotalText({ plannedTotalMinutes: 165, atLeast: true }, t)).toBe('tours.walk.totalAtLeast:2 h 45 min')
expect(plannedTotalText({ plannedTotalMinutes: 165, atLeast: true }, t, true)).toBe('≥ 2 h 45 min')
})
it('uses the source abstraction for TREK, GPX import, and wanderer badges', () => {
const t = vi.fn((key: string) => key)
@@ -1,6 +1,7 @@
import type { TourListItem, TourOrigin } from '@trek/shared'
import { tourWebsiteSchema } from '@trek/shared'
import { formatDurationShort } from '@trek/shared/roadtrip'
import { walkTimesOf, type WalkTimes } from './planner/stationModel'
export type TourSource = TourOrigin
@@ -21,20 +22,58 @@ export interface TourPlannedTimes {
breakMinutes: number | null
plannedTotalMinutes: number | null
manuallyOverridden: boolean
/** CW-02: a city walk's walking time and stays as the list knows them; null for any other type, or a list without them. */
walk: WalkTimes | null
/** The planned total leaves out a stay nobody entered yet: it is at least that much. */
atLeast: boolean
}
export function tourPlannedTimes(tour: Pick<TourListItem, 'duration' | 'planned_duration_minutes' | 'break_additional_minutes'>): TourPlannedTimes {
type PlannedTimesSource = Pick<TourListItem, 'duration' | 'planned_duration_minutes' | 'break_additional_minutes'>
& Partial<Pick<TourListItem, 'tour_type' | 'station_count' | 'station_stay_minutes' | 'station_stays_unknown'>>
/** CW-02: a city walk's times from what the list says of its stations; null where it says nothing of them. */
function walkTimesOfTour(tour: PlannedTimesSource): WalkTimes | null {
if (tour.tour_type !== 'walk' || tour.station_stay_minutes == null) return null
return walkTimesOf({
walkingMinutes: tour.duration,
stayMinutes: tour.station_stay_minutes,
unknownStays: tour.station_stays_unknown ?? 0,
stations: tour.station_count ?? 0,
})
}
/**
* The times a tour is planned with. A city walk takes no breaks: its stops are
* its stays, so a break kept from another type never counts on top (CW-02), and
* its total is the walking time and the stays entered.
*/
export function tourPlannedTimes(tour: PlannedTimesSource): TourPlannedTimes {
const manualOverride = tour.planned_duration_minutes ?? null
const breaks = tour.break_additional_minutes ?? 0
const plannedTotal = manualOverride ?? (tour.duration == null ? null : tour.duration + breaks)
const walk = walkTimesOfTour(tour)
const breaks = tour.tour_type === 'walk' ? null : (tour.break_additional_minutes ?? null)
const automatic = walk ? walk.totalMinutes : (tour.duration == null ? null : tour.duration + (breaks ?? 0))
return {
walkingMinutes: tour.duration,
breakMinutes: tour.break_additional_minutes ?? null,
plannedTotalMinutes: plannedTotal,
breakMinutes: breaks,
plannedTotalMinutes: manualOverride ?? automatic,
manuallyOverridden: manualOverride !== null,
walk,
atLeast: manualOverride === null && !!walk?.partial,
}
}
/**
* The planned total as said, or null without one: "at least" where a city
* walk's stay is not entered yet (CW-02). A badge says it as "≥", the way a
* sum short of the whole reads in the tour summary.
*/
export function plannedTotalText(times: Pick<TourPlannedTimes, 'plannedTotalMinutes' | 'atLeast'>, t: Translate, badge = false): string | null {
if (times.plannedTotalMinutes == null) return null
const time = formatPlannedTourDuration(times.plannedTotalMinutes)
if (!times.atLeast) return time
return badge ? `≥ ${time}` : t('tours.walk.totalAtLeast', { time })
}
const TOUR_WEBSITE_PROVIDERS = [
{ provider: 'Komoot', domains: ['komoot.com'] },
{ provider: 'AllTrails', domains: ['alltrails.com'] },
+33 -1
View File
@@ -90,7 +90,23 @@ describe('tourScopeSummary (STA-01)', () => {
expect(hike.routedMinutes).toEqual({ value: 300, known: 2, of: 2 });
expect(hike.importedMinutes).toEqual({ value: 150, known: 1, of: 1 });
// Planned: each tour's own time plus its breaks (HIKE_B's 30 min).
expect(hike.plannedMinutes).toEqual({ value: 480, known: 3, of: 3 });
expect(hike.plannedMinutes).toEqual({ value: 480, known: 3, of: 3, atLeast: false });
});
it('CW-02: plans a city walk with its stays and no breaks, at least that much while a stay is not entered', () => {
const walk = tour({
place_id: 6,
tour_type: 'walk',
duration: 45,
break_additional_minutes: 15,
station_count: 3,
station_stay_minutes: 120,
station_stays_unknown: 0,
});
const [entered] = tourScopeSummary([walk]).types;
expect(entered.plannedMinutes).toEqual({ value: 165, known: 1, of: 1, atLeast: false });
const [open] = tourScopeSummary([walk, { ...walk, place_id: 7, station_stays_unknown: 1 }]).types;
expect(open.plannedMinutes).toEqual({ value: 330, known: 2, of: 2, atLeast: true });
});
it('sums no heights for paddling, not even those a recorded paddle track brought', () => {
@@ -128,6 +144,22 @@ describe('TourScopeSummary', () => {
expect(screen.getByTestId('tour-scope-kayak-distance')).toHaveTextContent('12 km');
});
it('CW-02: marks a planned time whose city walk has a stay not entered as at least, and says so', () => {
const walk = tour({
place_id: 6,
tour_type: 'walk',
duration: 45,
station_count: 3,
station_stay_minutes: 90,
station_stays_unknown: 1,
});
render(<TourScopeSummary tours={[walk]} />);
fireEvent.click(screen.getByTestId('tour-scope-toggle'));
const planned = screen.getByTestId('tour-scope-walk-planned');
expect(planned).toHaveTextContent('≥ 2 h 15 min');
expect(planned).toHaveAccessibleName('Planned with stays: at least 2 h 15 min');
});
it('starts folded to its counts, and remembers an opening in this browser', () => {
const { unmount } = render(<TourScopeSummary tours={[HIKE_A]} />);
expect(screen.queryByTestId('tour-scope-hike')).toBeNull();
+12 -1
View File
@@ -11,6 +11,8 @@ export interface ScopeSum {
value: number | null;
known: number;
of: number;
/** CW-02: a value counted is itself only what is known of it (a city walk's stay not entered yet): the sum is at least this. */
atLeast?: boolean;
}
/** One tour type's share of the trip: how many tours, how many on a day, and their measures. */
@@ -64,7 +66,16 @@ function scopeOfType(type: TourTypeKey, tours: readonly TourListItem[], onDay: O
descentM: heights ? sumOf(withHeights.map((tour) => tour?.elevation_loss)) : NO_SUM,
routedMinutes: sumOf(planned.map((tour) => tour.duration)),
importedMinutes: sumOf(imported.map((tour) => tour.duration)),
plannedMinutes: sumOf(tours.map((tour) => tourPlannedTimes(tour).plannedTotalMinutes)),
plannedMinutes: plannedSum(tours),
};
}
/** The planned totals summed; at least the sum where a city walk's total leaves out a stay nobody entered (CW-02). */
function plannedSum(tours: readonly TourListItem[]): ScopeSum {
const times = tours.map((tour) => tourPlannedTimes(tour));
return {
...sumOf(times.map((time) => time.plannedTotalMinutes)),
atLeast: times.some((time) => time.atLeast),
};
}
@@ -0,0 +1,140 @@
// CW-04: a station's arrival, end of stay and opening hours as the planner and
// the walk's detail both show them.
import { beforeEach, describe, expect, it } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { StationScheduleLine, StationScheduleRow } from './StationSchedule';
import type { StationSchedule, WalkSchedule } from './walkSchedule';
const at = (overrides: Partial<StationSchedule>): StationSchedule => ({
arrival: 1040,
atLeast: false,
leave: 1100,
fit: { kind: 'unknown' },
...overrides,
});
const line = (schedule: StationSchedule | undefined) =>
render(<StationScheduleLine schedule={schedule} name="Lenbachhaus" />);
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { time_format: '24h' } });
});
describe('StationScheduleLine', () => {
it('names station, arrival, end of stay and window for the card case: 17:20 to 18:20 against a closing at 18:00', () => {
line(at({ fit: { kind: 'closes', source: 'osm', window: [600, 1080] } }));
expect(screen.getByText('Arrival about 17:20, stay until 18:20')).toBeInTheDocument();
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)'
);
expect(screen.getByTestId('walk-hours-source')).toHaveTextContent('OpenStreetMap');
expect(screen.getByText('Found with the stop on OpenStreetMap; it may be out of date.')).toBeInTheDocument();
});
it('follows the 12 hour format, a window to midnight included', () => {
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
const { unmount } = line(at({ leave: 1130, fit: { kind: 'closes', source: 'place', window: [600, 1080] } }));
expect(screen.getByText('Arrival about 5:20 PM, stay until 6:50 PM')).toBeInTheDocument();
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus closes at 6:00 PM, during the stay from 5:20 PM to 6:50 PM (open 10:00 AM to 6:00 PM)'
);
expect(screen.getByTestId('walk-hours-source')).toHaveTextContent('Trip place');
// In 12 hours a window to midnight ends at 12:00 AM, never at 24:00.
unmount();
line(at({ fit: { kind: 'open', source: 'place', window: [600, 1440] } }));
expect(screen.getByText(/Open 10:00 AM to 12:00 AM/)).toBeInTheDocument();
});
it('shows the window an open station is checked against; one to midnight reads 24:00', () => {
line(at({ fit: { kind: 'open', source: 'place', window: [600, 1440] } }));
expect(screen.getByText(/Open 10:00 to 24:00/)).toBeInTheDocument();
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
});
it('names a station closed at its arrival with the windows of the day, with and without a stay', () => {
const fit = {
kind: 'closed',
source: 'osm',
windows: [
[600, 720],
[840, 1080],
],
} as const;
const { unmount } = line(at({ arrival: 780, leave: 810, fit }));
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus is closed at 13:00, the stay would last until 13:30; open 10:00 to 12:00, 14:00 to 18:00'
);
unmount();
line(at({ arrival: 780, leave: null, fit }));
expect(screen.getByText('Arrival about 13:00')).toBeInTheDocument();
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus is closed at 13:00; open 10:00 to 12:00, 14:00 to 18:00'
);
});
it('names a station closed all day, and a point only passed by its arrival alone', () => {
line(at({ leave: 1040, fit: { kind: 'closedAllDay', source: 'osm' } }));
expect(screen.getByText('Arrival about 17:20')).toBeInTheDocument();
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent('Lenbachhaus is closed all day on this date');
});
it('for an arrival at the earliest, says the hours are not checked yet', () => {
line(at({ atLeast: true, fit: { kind: 'uncertain', source: 'osm' } }));
expect(screen.getByText('Arrival 17:20 at the earliest, stay until 18:20 or later')).toBeInTheDocument();
expect(
screen.getByText(/Opening hours not checked: a stay before this stop is not entered yet\./)
).toBeInTheDocument();
const { container } = line(at({ atLeast: true, leave: null }));
expect(container).toHaveTextContent('Arrival 17:20 at the earliest');
});
it('gives hours it cannot read back as written, and says nothing for hours not known', () => {
const { unmount } = line(at({ fit: { kind: 'unread', text: 'PH off' } }));
expect(screen.getByText('Opening hours not read here: PH off')).toBeInTheDocument();
unmount();
line(at({ fit: { kind: 'unknown' } }));
expect(screen.getByTestId('walk-station-hours')).toHaveTextContent('Arrival about 17:20, stay until 18:20');
expect(screen.queryByText(/Open/)).not.toBeInTheDocument();
expect(screen.queryByTestId('walk-hours-source')).not.toBeInTheDocument();
});
it('says an arrival after midnight is on the next day and is not checked', () => {
line(at({ arrival: 1470, leave: 1500, fit: { kind: 'nextDay' } }));
expect(
screen.getByText('Arrival about 00:30 the next day; opening hours are not checked across midnight.')
).toBeInTheDocument();
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
});
it('shows nothing without a schedule', () => {
const { container } = line(undefined);
expect(container).toBeEmptyDOMElement();
});
});
describe('StationScheduleRow', () => {
const schedule: WalkSchedule = {
state: 'ready',
anyHours: true,
byStation: new Map([['a', at({ fit: { kind: 'closedAllDay', source: 'osm' } })]]),
};
it("names the station by its own name, else the place's, else its number", () => {
const { rerender } = render(
<StationScheduleRow schedule={schedule} point={{ id: 'a', name: 'Museum' }} placeName="Square" number={2} />
);
expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Museum is closed all day');
rerender(<StationScheduleRow schedule={schedule} point={{ id: 'a' }} placeName="Square" number={2} />);
expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Square is closed all day');
rerender(<StationScheduleRow schedule={schedule} point={{ id: 'a' }} placeName={undefined} number={2} />);
expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Stop 2 is closed all day');
});
it('shows nothing for a point the schedule has no station for', () => {
render(<StationScheduleRow schedule={schedule} point={{ id: 'b' }} placeName={undefined} number={3} />);
expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,177 @@
import { clockOf, type HoursSource, type HoursWindow, type StationFit } from '@trek/shared';
import { AlertTriangle, Clock } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatClockTime } from '../../../utils/formatters';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { StationSchedule, WalkSchedule } from './walkSchedule';
type Translate = ReturnType<typeof useTranslation>['t'];
/** Where a station's hours come from, named on the pill, with how far to trust them behind it. */
const SOURCES: Record<HoursSource, { labelKey: string; hintKey: string }> = {
osm: { labelKey: 'tours.walk.hours.sourceOsm', hintKey: 'tours.walk.hours.sourceOsmHint' },
place: { labelKey: 'tours.walk.hours.sourcePlace', hintKey: 'tours.walk.hours.sourcePlaceHint' },
};
interface ClockFormat {
/** A time of the day in the user's 12 or 24 hours. */
at: (minutes: number) => string;
/** The end of a window: one running to midnight reads 24:00 in 24 hours. */
end: (minutes: number) => string;
}
function useClock(): ClockFormat {
const is12h = useSettingsStore((state) => state.settings.time_format) === '12h';
const at = (minutes: number) => formatClockTime(clockOf(minutes), is12h);
return { at, end: (minutes) => (minutes >= 24 * 60 && !is12h ? '24:00' : at(minutes)) };
}
function windowsText(windows: readonly HoursWindow[], clock: ClockFormat, t: Translate): string {
return windows
.map(([from, to]) => t('tours.walk.hours.window', { from: clock.at(from), to: clock.end(to) }))
.join(', ');
}
/** The arrival, and the end of the stay where it lasts; the next day said as such. */
function arrivalText(schedule: StationSchedule, clock: ClockFormat, t: Translate): string {
const arrival = clock.at(schedule.arrival);
if (schedule.fit.kind === 'nextDay') return t('tours.walk.arrivalNextDay', { time: arrival });
const leave = schedule.leave ?? schedule.arrival;
if (leave > schedule.arrival) {
return t(schedule.atLeast ? 'tours.walk.arrivalStayAtLeast' : 'tours.walk.arrivalStay', {
arrival,
leave: clock.at(leave),
});
}
return t(schedule.atLeast ? 'tours.walk.arrivalAtLeast' : 'tours.walk.arrival', { time: arrival });
}
type Conflict = Extract<StationFit, { kind: 'closes' | 'closed' | 'closedAllDay' }>;
/** A conflict in one sentence: the station, the arrival, the end of the stay and the window it was checked against. */
function conflictText(
fit: Conflict,
schedule: StationSchedule,
name: string,
clock: ClockFormat,
t: Translate
): string {
const arrival = clock.at(schedule.arrival);
const leave = schedule.leave ?? schedule.arrival;
if (fit.kind === 'closedAllDay') return t('tours.walk.hours.closedAllDay', { name });
if (fit.kind === 'closes') {
return t('tours.walk.hours.closes', {
name,
closes: clock.end(fit.window[1]),
arrival,
leave: clock.at(leave),
window: windowsText([fit.window], clock, t),
});
}
const open = windowsText(fit.windows, clock, t);
return leave > schedule.arrival
? t('tours.walk.hours.closed', { name, arrival, leave: clock.at(leave), window: open })
: t('tours.walk.hours.closedNoStay', { name, arrival, window: open });
}
function SourcePill({ source }: { source: HoursSource }) {
const { t } = useTranslation();
const { labelKey, hintKey } = SOURCES[source];
return (
<Tooltip label={t(hintKey)}>
<span className="inline-flex" data-testid="walk-hours-source">
<SoftPill>{t(labelKey)}</SoftPill>
<span className="sr-only">{t(hintKey)}</span>
</span>
</Tooltip>
);
}
/** What the hours say: a conflict in the warning tone, otherwise a quiet line; nothing where none are known. */
function FitLine({ schedule, name, clock }: { schedule: StationSchedule; name: string; clock: ClockFormat }) {
const { t } = useTranslation();
const { fit } = schedule;
if (fit.kind === 'unknown' || fit.kind === 'nextDay') return null;
if (fit.kind === 'unread') {
return (
<span className="break-words text-content-faint" style={fs(10.5, 'body')}>
{t('tours.walk.hours.unread', { text: fit.text })}
</span>
);
}
if (fit.kind === 'open' || fit.kind === 'uncertain') {
return (
<span className="inline-flex flex-wrap items-center gap-1 text-content-faint" style={fs(10.5, 'body')}>
{fit.kind === 'open'
? t('tours.walk.hours.open', { window: windowsText([fit.window], clock, t) })
: t('tours.walk.hours.uncertain')}
<SourcePill source={fit.source} />
</span>
);
}
return (
<span
className="inline-flex flex-wrap items-start gap-1 text-warning"
style={fs(11, 'body')}
data-testid="walk-station-conflict"
>
<AlertTriangle size={11} strokeWidth={2.2} className="mt-0.5 flex-none" aria-hidden="true" />
<span className="min-w-0 flex-1 break-words">{conflictText(fit, schedule, name, clock, t)}</span>
<SourcePill source={fit.source} />
</span>
);
}
/**
* CW-04: a station on the day looked at: its expected arrival, the end of its
* stay, and what its opening hours say, with where they come from. The same
* lines in the planner and in the walk's detail; nothing is changed by them.
*/
export function StationScheduleLine({ schedule, name }: { schedule: StationSchedule | undefined; name: string }) {
const { t } = useTranslation();
const clock = useClock();
if (!schedule) return null;
return (
<span className="flex flex-col gap-0.5" data-testid="walk-station-hours">
<span className="inline-flex items-start gap-1 text-content-muted" style={fs(11, 'body')}>
<Clock size={11} strokeWidth={2.2} className="mt-0.5 flex-none" aria-hidden="true" />
<span className="min-w-0 break-words">{arrivalText(schedule, clock, t)}</span>
</span>
<FitLine schedule={schedule} name={name} clock={clock} />
</span>
);
}
/**
* The station's lines under its row in the planner, beside its select button
* and aligned with its name; nothing for a point that is no station.
*/
export function StationScheduleRow({
schedule,
point,
placeName,
number,
}: {
schedule: WalkSchedule;
point: Pick<TourPlannerWaypoint, 'id' | 'name'>;
/** The name the row shows for a point without its own: the trip place it stands for, else the place found there. */
placeName: string | undefined;
/** The stop's number, for a station without any name. */
number: number;
}) {
const { t } = useTranslation();
const at = schedule.byStation.get(point.id);
if (!at) return null;
return (
<div className="pb-1 pe-1.5 ps-[66px]" data-testid="walk-station-schedule">
<StationScheduleLine
schedule={at}
name={point.name ?? placeName ?? t('tours.walk.stationNumbered', { number })}
/>
</div>
);
}
@@ -0,0 +1,114 @@
// CW-02, CW-04: the day a city walk's times are for, picked where there is a
// choice, what is missing for them and where the hours come from.
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildDay } from '../../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { resetAllStores } from '../../../../tests/helpers/store';
import type { WalkDayChoice, WalkDayOption } from './useWalkDay';
import { WalkDayLine } from './WalkDayLine';
import type { WalkSchedule, WalkScheduleState } from './walkSchedule';
const monday: WalkDayOption = {
day: buildDay({ id: 1, date: '2026-10-19', title: 'Old town' }),
index: 0,
start: null,
};
const tuesday: WalkDayOption = { day: buildDay({ id: 2, date: '2026-10-20' }), index: 1, start: '09:30' };
const schedule = (state: WalkScheduleState, anyHours = false): WalkSchedule => ({
state,
anyHours,
byStation: new Map(),
});
const choice = (overrides: Partial<WalkDayChoice> = {}): WalkDayChoice => ({
options: [monday, tuesday],
day: tuesday,
fixed: false,
choose: vi.fn(),
...overrides,
});
const NO_DAY = 'Put the walk on a day with a start time to see arrivals and opening hours.';
beforeEach(() => resetAllStores());
describe('WalkDayLine', () => {
it("lets another of the walk's days be picked from a menu, the one shown marked", () => {
function Picker() {
const [day, setDay] = useState<WalkDayOption>(tuesday);
const pick = (id: number) => setDay(id === 1 ? monday : tuesday);
return <WalkDayLine choice={choice({ day, choose: pick })} schedule={schedule('ready')} stationCount={3} />;
}
render(<Picker />);
expect(screen.getByText('Times for')).toBeInTheDocument();
const picker = screen.getByTestId('walk-day-picker');
// The pill names the date the hours are for, as the menu does.
expect(picker).toHaveTextContent(/^Day 2, Tue, Oct 20(, 2026)?, start 09:30$/);
expect(picker).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(picker);
expect(picker).toHaveAttribute('aria-expanded', 'true');
const items = [...screen.getByRole('menu').querySelectorAll('button')];
expect(items.map((item) => item.textContent)).toEqual([
expect.stringMatching(/^Day 1, .*: Old town, no start time$/),
expect.stringMatching(/^Day 2, .*, start 09:30$/),
]);
expect(items.map((item) => !!item.querySelector('svg'))).toEqual([false, true]);
fireEvent.click(screen.getByRole('button', { name: /^Day 1, / }));
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent(
/^Day 1, Mon, Oct 19(, 2026)?: Old town, no start time$/
);
});
it('closes the menu again from its pill', () => {
render(<WalkDayLine choice={choice()} schedule={schedule('ready')} stationCount={3} />);
const picker = screen.getByTestId('walk-day-picker');
fireEvent.click(picker);
expect(screen.getByRole('menu')).toBeInTheDocument();
fireEvent.click(picker);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
it('names the day alone where it is fixed or the only one', () => {
const { rerender } = render(
<WalkDayLine choice={choice({ fixed: true })} schedule={schedule('ready')} stationCount={3} />
);
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 2, Tue, Oct 20(, 2026)?, start 09:30$/);
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument();
rerender(
<WalkDayLine
choice={choice({ options: [monday], day: monday })}
schedule={schedule('noStart')}
stationCount={3}
/>
);
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, .+: Old town, no start time$/);
expect(screen.getByText('Give the walk a time on this day to see arrivals and opening hours.')).toBeInTheDocument();
});
it('says what is missing: a day once there are stops, a date', () => {
const none = choice({ options: [], day: null });
const { rerender, container } = render(<WalkDayLine choice={none} schedule={schedule('noDay')} stationCount={2} />);
expect(screen.getByText(NO_DAY)).toBeInTheDocument();
rerender(<WalkDayLine choice={none} schedule={schedule('noDay')} stationCount={1} />);
expect(container).toBeEmptyDOMElement();
// Opened from a day the walk has left: that day is not replaced, and nothing is asked.
rerender(<WalkDayLine choice={{ ...none, fixed: true }} schedule={schedule('noDay')} stationCount={3} />);
expect(container).toBeEmptyDOMElement();
rerender(<WalkDayLine choice={choice()} schedule={schedule('noDate')} stationCount={3} />);
expect(screen.getByText('This day has no date, so arrivals and opening hours are left out.')).toBeInTheDocument();
});
it('names where the hours come from once there are any to go by, and nothing for another tour', () => {
const { rerender, container } = render(
<WalkDayLine choice={choice()} schedule={schedule('ready', true)} stationCount={3} />
);
expect(screen.getByText(/Opening hours from OpenStreetMap or the trip's places/)).toBeInTheDocument();
rerender(<WalkDayLine choice={choice()} schedule={schedule('noRoute', true)} stationCount={3} />);
expect(screen.queryByText(/Opening hours from OpenStreetMap/)).not.toBeInTheDocument();
rerender(<WalkDayLine enabled={false} choice={choice()} schedule={schedule('ready', true)} stationCount={3} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,123 @@
import { Check, ChevronDown, Clock } from 'lucide-react';
import type { MouseEvent } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatClockTime } from '../../../utils/formatters';
import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
import { fs } from '../../shared/DialogShell';
import { dayMenuLabel } from '../tourDays';
import { TourNotice } from '../tourParts';
import type { WalkDayChoice, WalkDayOption } from './useWalkDay';
import type { WalkSchedule, WalkScheduleState } from './walkSchedule';
type Translate = ReturnType<typeof useTranslation>['t'];
const PILL =
'inline-flex min-w-0 items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-start font-medium text-content-secondary shadow-sm';
/** "Day 2, Tue, Jul 8, start 09:00", or that it has no start time; `day` names the day with its date. */
function optionLabel(option: WalkDayOption, day: string, t: Translate, is12h: boolean): string {
return option.start
? t('tours.walk.day.start', { day, time: formatClockTime(option.start, is12h) })
: t('tours.walk.day.noStart', { day });
}
/** What keeps the arrivals from being worked out, said once; a route still being planned says so elsewhere. */
function missingNote(state: WalkScheduleState, choice: WalkDayChoice, stationCount: number, t: Translate) {
if (state === 'noDay') return !choice.fixed && stationCount >= 2 ? t('tours.walk.hours.noDay') : null;
if (state === 'noDate') return t('tours.walk.hours.noDate');
if (state === 'noStart') return t('tours.walk.hours.noStart');
return null;
}
/**
* The day as a pill: a menu of the walk's days where there is a choice, its name alone where there is none.
* It names the day with its date as the menu does, so the opening hours below read for a day that is in view.
*/
function DayPill({ choice, day }: { choice: WalkDayChoice; day: WalkDayOption }) {
const { t, locale } = useTranslation();
const is12h = useSettingsStore((state) => state.settings.time_format) === '12h';
const menu = useContextMenu();
const label = optionLabel(day, dayMenuLabel(day.day, day.index, t, locale), t, is12h);
if (choice.fixed || choice.options.length < 2) {
return (
<span data-testid="walk-day" className={`${PILL} break-words`} style={fs(11, 'body')}>
{label}
</span>
);
}
const items = choice.options.map((option) => ({
label: optionLabel(option, dayMenuLabel(option.day, option.index, t, locale), t, is12h),
icon: option === day ? Check : undefined,
onClick: () => choice.choose(option.day.id),
}));
const toggle = (event: MouseEvent<HTMLButtonElement>) => {
if (menu.menu) {
event.stopPropagation();
menu.close();
} else menu.open(event, items, true);
};
return (
<>
<button
type="button"
data-testid="walk-day-picker"
aria-haspopup="menu"
aria-expanded={!!menu.menu}
onClick={toggle}
className={`${PILL} transition-colors hover:text-content`}
style={fs(11, 'body')}
>
<span className="min-w-0 break-words">{label}</span>
<ChevronDown size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
</button>
<ContextMenu menu={menu.menu} onClose={menu.close} />
</>
);
}
/**
* CW-02, CW-04: the day a city walk's arrivals and opening hours are worked
* out for, above its stations, in the planner and in the walk's detail alike;
* what is missing for them; and, once there are hours to go by, where they
* come from and that they are a hint to check.
*/
export function WalkDayLine({
enabled = true,
choice,
schedule,
stationCount,
className = '',
}: {
/** Only a city walk has the line. */
enabled?: boolean;
choice: WalkDayChoice;
schedule: WalkSchedule;
stationCount: number;
className?: string;
}) {
const { t } = useTranslation();
if (!enabled) return null;
const note = missingNote(schedule.state, choice, stationCount, t);
if (!choice.day && !note) return null;
return (
<div data-testid="walk-day-line" className={`flex flex-col gap-1.5 ${className}`}>
{choice.day && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-content-faint" style={fs(11, 'body')}>
{t('tours.walk.day.label')}
</span>
<DayPill choice={choice} day={choice.day} />
</div>
)}
{note && (
<p className="m-0 text-content-faint" style={fs(11, 'body')}>
{note}
</p>
)}
{schedule.state === 'ready' && schedule.anyHours && (
<TourNotice icon={<Clock size={13} strokeWidth={2} />}>{t('tours.walk.hours.source')}</TourNotice>
)}
</div>
);
}
@@ -0,0 +1,137 @@
import type { WalkImportPreviewResponse } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import type { WalkImport } from './useWalkImport';
import { WalkImportDialog, WalkImportDraftNotice } from './WalkImportDialog';
// FE-WALK-IMPORT-DIALOG-001 to FE-WALK-IMPORT-DIALOG-006
const ROUTE = 'https://www.google.com/maps/dir/Cathedral/Market/Park/data=!3e2';
const PREVIEW: WalkImportPreviewResponse = {
stations: [
{ name: 'Cathedral', lat: 48.1, lng: 11.5 },
{ name: 'Market', lat: null, lng: null },
{ name: null, lat: 48.12, lng: 11.52 },
],
resolved_url: ROUTE,
};
function walkImport(over: Partial<WalkImport> = {}): WalkImport {
return {
open: true,
show: vi.fn(),
close: vi.fn(),
url: '',
setUrl: vi.fn(),
phase: 'link',
preview: null,
failure: null,
unplaced: 0,
canRead: false,
canLoad: false,
read: vi.fn().mockResolvedValue(undefined),
back: vi.fn(),
load: vi.fn(),
...over,
};
}
const atPreview = (over: Partial<WalkImport> = {}) =>
walkImport({ phase: 'preview', preview: PREVIEW, unplaced: 1, canLoad: true, url: ROUTE, ...over });
describe('WalkImportDialog (CW-07)', () => {
it('FE-WALK-IMPORT-DIALOG-001: asks for the link, reads it on the button and on Enter, and says why one was refused', () => {
const closed = walkImport({ open: false });
const { rerender } = render(<WalkImportDialog walkImport={closed} hasUnsavedChanges={false} />);
expect(screen.queryByRole('dialog')).toBeNull();
const w = walkImport({ url: 'https://maps.app.goo.gl/abc', canRead: true });
rerender(<WalkImportDialog walkImport={w} hasUnsavedChanges={false} />);
expect(screen.getByText('Walk from a Google Maps link')).toBeInTheDocument();
const input = screen.getByLabelText('Link to the walking route');
fireEvent.change(input, { target: { value: 'https://maps.app.goo.gl/xyz' } });
expect(w.setUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/xyz');
fireEvent.click(screen.getByTestId('walk-import-read'));
expect(w.read).toHaveBeenCalledTimes(1);
fireEvent.submit(input.closest('form') as HTMLFormElement);
expect(w.read).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByText('Cancel'));
expect(w.close).toHaveBeenCalledTimes(1);
const refused = walkImport({ failure: 'That route is not planned on foot.' });
rerender(<WalkImportDialog walkImport={refused} hasUnsavedChanges={false} />);
expect(screen.getByText('That route is not planned on foot.')).toBeInTheDocument();
expect(screen.getByTestId('walk-import-read')).toBeDisabled();
fireEvent.submit(screen.getByLabelText('Link to the walking route').closest('form') as HTMLFormElement);
expect(refused.read).not.toHaveBeenCalled();
});
it('FE-WALK-IMPORT-DIALOG-002: waits while the link is read', () => {
render(<WalkImportDialog walkImport={walkImport({ phase: 'reading', url: ROUTE })} hasUnsavedChanges={false} />);
expect(screen.getByLabelText('Link to the walking route')).toBeDisabled();
expect(screen.getByTestId('walk-import-read')).toBeDisabled();
});
it('FE-WALK-IMPORT-DIALOG-003: shows the stops in their order, the one without a position marked', () => {
render(<WalkImportDialog walkImport={atPreview()} hasUnsavedChanges={false} />);
const rows = screen.getAllByTestId('walk-import-station');
expect(rows.map((row) => row.textContent)).toEqual([
'1Cathedral48.10000, 11.50000',
'2MarketNo position in the link',
'3Stop 348.12000, 11.52000',
]);
expect(rows.map((row) => row.getAttribute('data-unresolved'))).toEqual([null, 'true', null]);
expect(screen.getByText('1 stop has no position in the link and is left out.')).toBeInTheDocument();
expect(
screen.getByText('TREK plans the way between the stops again; the line drawn in Google Maps is not taken over.')
).toBeInTheDocument();
expect(screen.queryByText('A walk needs at least two stops with a position.')).toBeNull();
expect(screen.queryByText('Loading the walk replaces the unsaved changes in the planner.')).toBeNull();
});
it('FE-WALK-IMPORT-DIALOG-004: names Google Maps as the source and opens the route read there in a new tab', () => {
render(<WalkImportDialog walkImport={atPreview()} hasUnsavedChanges={false} />);
const preview = screen.getByTestId('walk-import-preview');
expect(within(preview).getByText('From Google Maps')).toBeInTheDocument();
const source = screen.getByTestId('walk-import-source');
expect(source).toHaveAttribute('href', ROUTE);
expect(source).toHaveAttribute('target', '_blank');
expect(source).toHaveAttribute('rel', 'noopener noreferrer');
expect(source).toHaveTextContent('Open in Google Maps');
});
it('FE-WALK-IMPORT-DIALOG-005: loads or goes back, warns before replacing unsaved work, and loads nothing with too few positions', () => {
const w = atPreview();
const { rerender } = render(<WalkImportDialog walkImport={w} hasUnsavedChanges />);
expect(screen.getByText('Loading the walk replaces the unsaved changes in the planner.')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('walk-import-load'));
expect(w.load).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText('Use another link'));
expect(w.back).toHaveBeenCalledTimes(1);
// Enter does not read again while the stops are shown.
fireEvent.submit(screen.getByTestId('walk-import-preview').closest('form') as HTMLFormElement);
expect(w.read).not.toHaveBeenCalled();
rerender(<WalkImportDialog walkImport={atPreview({ canLoad: false, unplaced: 2 })} hasUnsavedChanges={false} />);
expect(screen.getByTestId('walk-import-load')).toBeDisabled();
expect(screen.getByText('A walk needs at least two stops with a position.')).toBeInTheDocument();
expect(screen.getByText('2 stops have no position in the link and are left out.')).toBeInTheDocument();
});
});
describe('WalkImportDraftNotice (CW-07)', () => {
it('FE-WALK-IMPORT-DIALOG-006: says where an unsaved walk came from, and nothing for any other draft', () => {
const { rerender } = render(
<WalkImportDraftNotice planner={{ draftSource: 'google_maps', mode: { type: 'new-draft' } }} />
);
expect(
screen.getByText('From a Google Maps walking route. TREK planned the way between the stops itself.')
).toBeInTheDocument();
rerender(
<WalkImportDraftNotice planner={{ draftSource: 'google_maps', mode: { type: 'edit-saved', placeId: 3 } }} />
);
expect(screen.queryByRole('status')).toBeNull();
rerender(<WalkImportDraftNotice planner={{ draftSource: null, mode: { type: 'new-draft' } }} />);
expect(screen.queryByRole('status')).toBeNull();
});
});
@@ -0,0 +1,251 @@
import type { WalkImportPreviewResponse, WalkImportStation } from '@trek/shared';
import { AlertTriangle, ExternalLink, Footprints, Link2, Loader2, MapPinOff, Route } from 'lucide-react';
import { useId, type FormEvent } from 'react';
import { useTranslation } from '../../../i18n';
import { EditorField, INPUT } from '../../shared/dialogParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { TourNotice } from '../tourParts';
import type { WalkImport } from './useWalkImport';
/** One stop of the link, in its place in the order; a stop without a position says so instead of a position. */
function WalkImportStationRow({ station, number }: { station: WalkImportStation; number: number }) {
const { t } = useTranslation();
const position =
station.lat !== null && station.lng !== null ? `${station.lat.toFixed(5)}, ${station.lng.toFixed(5)}` : null;
const placed = position !== null;
return (
<li
data-testid="walk-import-station"
data-unresolved={placed ? undefined : 'true'}
className={`flex items-start gap-2.5 rounded-[10px] px-2.5 py-2 ${placed ? 'bg-surface-card' : 'border border-dashed border-edge'}`}
>
<span
className="mt-px grid h-5 w-5 flex-none place-items-center rounded-full bg-surface-secondary font-geist font-semibold text-content-muted"
style={fs(10.5)}
>
{number}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className={`break-words font-medium leading-snug ${placed ? 'text-content' : 'text-content-muted'}`}
style={fs(12.5, 'body')}
>
{station.name ?? t('tours.walk.stationNumbered', { number })}
</span>
{placed ? (
<span className="font-geist text-content-faint" style={fs(11)}>
{position}
</span>
) : (
<span className="inline-flex items-center gap-1 text-warning" style={fs(11, 'body')}>
<MapPinOff size={11} strokeWidth={2.2} className="flex-none" />
{t('tours.walkImport.noPosition')}
</span>
)}
</span>
</li>
);
}
/** The step that asks for the link. */
function WalkImportLinkStep({ walkImport }: { walkImport: WalkImport }) {
const { t } = useTranslation();
const inputId = useId();
return (
<EditorField
label={t('tours.walkImport.urlLabel')}
htmlFor={inputId}
hint={t('tours.walkImport.urlHint')}
error={walkImport.failure}
>
<input
id={inputId}
type="text"
inputMode="url"
autoComplete="off"
spellCheck={false}
className={INPUT}
value={walkImport.url}
placeholder="https://maps.app.goo.gl/…"
disabled={walkImport.phase === 'reading'}
onChange={(event) => walkImport.setUrl(event.target.value)}
data-testid="walk-import-url"
/>
</EditorField>
);
}
/** The step that shows what the link holds, before anything is loaded. */
function WalkImportPreviewStep({
preview,
unplaced,
canLoad,
hasUnsavedChanges,
}: {
preview: WalkImportPreviewResponse;
unplaced: number;
canLoad: boolean;
hasUnsavedChanges: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4" data-testid="walk-import-preview">
<p className="m-0 flex flex-wrap items-center gap-x-2 gap-y-1 text-content-muted" style={fs(12, 'body')}>
<Footprints size={13} strokeWidth={2} className="flex-none" />
<span>{t('tours.walkImport.source')}</span>
<a
href={preview.resolved_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-accent hover:underline"
data-testid="walk-import-source"
>
{t('tours.walkImport.openSource')}
<ExternalLink size={12} strokeWidth={2} className="flex-none" />
</a>
</p>
<TourNotice icon={<Route size={13} strokeWidth={2} />}>{t('tours.walkImport.recalculated')}</TourNotice>
<DialogSection label={t('tours.walkImport.stops')}>
<ol className="m-0 flex list-none flex-col gap-1 p-0">
{preview.stations.map((station, index) => (
<WalkImportStationRow key={index} station={station} number={index + 1} />
))}
</ol>
</DialogSection>
{unplaced > 0 && (
<TourNotice tone="warning" icon={<MapPinOff size={13} strokeWidth={2} />}>
{t('tours.walkImport.unresolved', { count: unplaced })}
</TourNotice>
)}
{!canLoad && <TourNotice tone="warning">{t('tours.walkImport.tooFewPositions')}</TourNotice>}
{hasUnsavedChanges && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.walkImport.replacesDraft')}
</TourNotice>
)}
</div>
);
}
/** The buttons of each step: read the link, or go back and load the walk. */
function WalkImportFooter({ walkImport }: { walkImport: WalkImport }) {
const { t } = useTranslation();
const reading = walkImport.phase === 'reading';
return (
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={walkImport.close}>{t('common.cancel')}</DialogButton>
{walkImport.preview ? (
<>
<DialogButton onClick={walkImport.back}>{t('tours.walkImport.back')}</DialogButton>
<DialogButton
variant="primary"
icon={<Footprints size={14} strokeWidth={2} />}
disabled={!walkImport.canLoad}
onClick={walkImport.load}
data-testid="walk-import-load"
>
{t('tours.walkImport.load')}
</DialogButton>
</>
) : (
<DialogButton
variant="primary"
icon={
reading ? (
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
) : (
<Link2 size={14} strokeWidth={2} />
)
}
disabled={!walkImport.canRead}
onClick={() => void walkImport.read()}
data-testid="walk-import-read"
>
{t('tours.walkImport.read')}
</DialogButton>
)}
</DialogFooter>
);
}
/**
* CW-07: a city walk from a Google Maps walking route, opened from "Add tour".
* First the link, then its stops in their order with the ones the link gives
* no position marked, where the route came from, and that TREK plans the way
* between them itself. Only the load button hands the walk to the planner.
*/
export function WalkImportDialog({
walkImport,
hasUnsavedChanges,
}: {
walkImport: WalkImport;
hasUnsavedChanges: boolean;
}) {
const { t } = useTranslation();
const titleId = useId();
if (!walkImport.open) return null;
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!walkImport.preview && walkImport.canRead) void walkImport.read();
};
const head = (
<DialogHeader
tile={
<DialogTile>
<Footprints size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={titleId}
onClose={walkImport.close}
title={t('tours.walkImport.title')}
sub={t('tours.walkImport.intro')}
subWraps
/>
);
return (
<DialogShell
onClose={walkImport.close}
labelledBy={titleId}
width="narrow"
align="top"
onSubmit={submit}
header={head}
footer={<WalkImportFooter walkImport={walkImport} />}
>
{walkImport.preview ? (
<WalkImportPreviewStep
preview={walkImport.preview}
unplaced={walkImport.unplaced}
canLoad={walkImport.canLoad}
hasUnsavedChanges={hasUnsavedChanges}
/>
) : (
<WalkImportLinkStep walkImport={walkImport} />
)}
</DialogShell>
);
}
/** CW-07: said in the planner while a walk read from Google Maps is not saved yet. */
export function WalkImportDraftNotice({ planner }: { planner: Pick<TourPlannerController, 'draftSource' | 'mode'> }) {
const { t } = useTranslation();
if (planner.draftSource !== 'google_maps' || planner.mode.type !== 'new-draft') return null;
return (
<TourNotice role="status" icon={<Footprints size={13} strokeWidth={2} />}>
{t('tours.walkImport.draftNotice')}
</TourNotice>
);
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { WalkRoutePendingHint, WalkRoutePendingNotice } from './WalkRoutePending';
// FE-WALK-ROUTE-PENDING-001 to FE-WALK-ROUTE-PENDING-002: a walk an assistant saved with its stops only (CW-11).
describe('WalkRoutePending (CW-11)', () => {
it('FE-WALK-ROUTE-PENDING-001: the planner says the way was just planned until the walk is saved with it', () => {
const { rerender } = render(
<WalkRoutePendingNotice planner={{ routePending: true, hasUnsavedChanges: true, status: 'ready' }} />
);
expect(screen.getByRole('status')).toHaveTextContent(
'Saved with its stops only. The way between them was planned just now; save the walk to keep it.'
);
// Saved, or open for someone who may not save it: nothing to say.
rerender(<WalkRoutePendingNotice planner={{ routePending: true, hasUnsavedChanges: false, status: 'ready' }} />);
expect(screen.queryByRole('status')).toBeNull();
rerender(<WalkRoutePendingNotice planner={{ routePending: false, hasUnsavedChanges: true, status: 'ready' }} />);
expect(screen.queryByRole('status')).toBeNull();
// Still being planned, or planning it failed: nothing was planned yet.
rerender(<WalkRoutePendingNotice planner={{ routePending: true, hasUnsavedChanges: true, status: 'routing' }} />);
expect(screen.queryByRole('status')).toBeNull();
rerender(
<WalkRoutePendingNotice planner={{ routePending: true, hasUnsavedChanges: true, status: 'routing-failed' }} />
);
expect(screen.queryByRole('status')).toBeNull();
});
it('FE-WALK-ROUTE-PENDING-002: the detail says where the way gets planned', () => {
render(<WalkRoutePendingHint />);
expect(
screen.getByText(
'Saved with its stops only. The way between them is planned when the walk is opened in the planner and saved.'
)
).toBeInTheDocument();
});
});
@@ -0,0 +1,29 @@
import { Route } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { TourNotice } from '../tourParts';
/**
* CW-11: said in the planner once a walk an assistant saved with its stops
* only has its way planned here and not saved yet; not while the way is still
* being planned, nor when planning it failed.
*/
export function WalkRoutePendingNotice({
planner,
}: {
planner: Pick<TourPlannerController, 'routePending' | 'hasUnsavedChanges' | 'status'>;
}) {
const { t } = useTranslation();
if (!planner.routePending || !planner.hasUnsavedChanges || planner.status !== 'ready') return null;
return (
<TourNotice role="status" icon={<Route size={13} strokeWidth={2} />}>
{t('tours.walk.routePendingPlanner')}
</TourNotice>
);
}
/** CW-11: the detail of a walk saved with its stops only, whose way is still to be planned. */
export function WalkRoutePendingHint() {
const { t } = useTranslation();
return <TourNotice icon={<Route size={13} strokeWidth={2} />}>{t('tours.walk.routePending')}</TourNotice>;
}
@@ -0,0 +1,417 @@
// CW-02, CW-03, CW-04: a city walk's stops in its detail, checked off for the
// day it was opened from, their arrivals and opening hours on the day looked
// at, and its progress as a pill of the desktop day row.
import type { TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { tourRepo } from '../../../repo/tourRepo';
import { tourVisitRepo } from '../../../repo/tourVisitRepo';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import { useTripStore } from '../../../store/tripStore';
import type { Assignment, Day } from '../../../types';
import { ToastContainer } from '../../shared/Toast';
import { WalkProgressPill, WalkStationsCard } from './WalkStations';
const walk: TourListItem = {
place_id: 42,
name: 'Old town',
tour_type: 'walk',
distance: 0.3,
elevation_gain: null,
elevation_loss: null,
duration: 20,
break_additional_minutes: null,
planned_duration_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: true,
caution: false,
station_count: 3,
};
const waypoints: TourWaypoint[] = [
{
lat: 48,
lng: 11,
role: 'start',
sequence: 0,
key: 'stop-01',
name: 'Museum',
stay_minutes: 30,
opening_hours: 'Mo-Fr 09:00-09:20',
},
{ lat: 48.0005, lng: 11, role: 'via', sequence: 1, key: 'shape-01', shaping: true },
{ lat: 48.001, lng: 11, role: 'via', sequence: 2, key: 'stop-02', opening_hours: 'sunrise-sunset' },
{ lat: 48.002, lng: 11, role: 'end', sequence: 3, name: 'Cafe', stay_minutes: 15, opening_hours: 'Mo 10:00-18:00' },
];
const ROUTE: Array<[number, number]> = [
[48, 11],
[48.001, 11],
[48.002, 11],
];
// 2026-10-19 is a Monday, 2026-10-20 a Tuesday.
const MONDAY = buildDay({ id: 7, trip_id: 4, date: '2026-10-19' });
const TUESDAY = buildDay({ id: 8, trip_id: 4, date: '2026-10-20' });
const ON_DAY = { id: 9, day_id: 7 };
/** The walk's entry on a day: its start that day, else the place's own time. */
function entry(dayId: number, time: string | null, placeTime: string | null = null): Assignment {
return buildAssignment({
id: dayId + 2,
day_id: dayId,
place_id: 42,
assignment_time: time,
place: buildPlace({ id: 42, place_time: placeTime }),
});
}
/** The walk on Monday from 09:00, opened from that day. */
function onMonday(time: string | null = '09:00', day: Day = MONDAY) {
return { assignment: ON_DAY, days: [day], assignments: { [String(day.id)]: [entry(day.id, time)] }, route: ROUTE };
}
function detail(points: TourWaypoint[] = waypoints): Promise<TourDetailResponse> {
return Promise.resolve({ tour: walk, waypoints: points });
}
function card(props: Partial<Parameters<typeof WalkStationsCard>[0]> = {}) {
return render(
<>
<ToastContainer />
<WalkStationsCard tour={walk} tripId={4} assignment={ON_DAY} canMark {...props} />
</>
);
}
const UNREAD = 'The stops of this walk could not be loaded. Open it again while online to see them and check them off.';
const rowOf = async (name: string) =>
(await screen.findAllByTestId('walk-station')).find((row) => row.textContent?.includes(name)) as HTMLElement;
beforeEach(() => {
resetAllStores();
useTourVisitStore.setState({ tripId: '4', byAssignment: {} });
vi.spyOn(tourRepo, 'detail').mockImplementation(() => detail());
});
afterEach(() => vi.restoreAllMocks());
describe('WalkStationsCard', () => {
it('shows nothing for a tour that is no city walk', () => {
render(<WalkStationsCard tour={{ ...walk, tour_type: 'hike' }} tripId={4} assignment={ON_DAY} canMark />);
expect(tourRepo.detail).not.toHaveBeenCalled();
expect(screen.queryByTestId('walk-stations')).not.toBeInTheDocument();
});
it('says it is loading until the stops are read', () => {
vi.mocked(tourRepo.detail).mockReturnValue(new Promise(() => undefined));
card();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('lists the stops in their order without the shaping points, an unnamed one by its number', async () => {
card();
const rows = await screen.findAllByTestId('walk-station');
expect(rows.map((row) => row.querySelector('.font-semibold')?.textContent)).toEqual(['Museum', 'Stop 2', 'Cafe']);
expect(tourRepo.detail).toHaveBeenCalledWith(4, 42, expect.any(AbortSignal));
});
it('opened without a day, offers nothing to check off and says where to', async () => {
card({ assignment: null });
await screen.findAllByTestId('walk-station');
expect(
screen.getByText('Open the walk from a day of the plan to check its stops off for that day.')
).toBeInTheDocument();
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
});
it('checks a stop off for its day and skips another, through the store', async () => {
const update = vi
.spyOn(tourVisitRepo, 'update')
.mockResolvedValueOnce({ visits: [{ assignment_id: 9, station_key: 'stop-01', status: 'visited' }] })
.mockResolvedValueOnce({
visits: [
{ assignment_id: 9, station_key: 'stop-01', status: 'visited' },
{ assignment_id: 9, station_key: 'stop-02', status: 'skipped' },
],
});
card();
fireEvent.click(within(await rowOf('Museum')).getByText('Mark visited'));
expect(update).toHaveBeenLastCalledWith(4, 9, 'stop-01', { status: 'visited' });
expect(await within(await rowOf('Museum')).findByText('Visited on this day')).toBeInTheDocument();
fireEvent.click(within(await rowOf('Stop 2')).getByText('Skip'));
expect(update).toHaveBeenLastCalledWith(4, 9, 'stop-02', { status: 'skipped' });
expect(await within(await rowOf('Stop 2')).findByText('Skipped on this day')).toBeInTheDocument();
});
it('a stop visited on that day says so and can be taken back', async () => {
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited' } } });
const update = vi.spyOn(tourVisitRepo, 'update').mockResolvedValue({ visits: [] });
card();
const museum = await rowOf('Museum');
expect(within(museum).getByText('Visited on this day')).toBeInTheDocument();
fireEvent.click(within(museum).getByText('Take back'));
expect(update).toHaveBeenCalledWith(4, 9, 'stop-01', { status: 'planned' });
});
it('a stop without a key has nothing to check off, and without the right to plan the day no stop has', async () => {
const { unmount } = card();
expect(within(await rowOf('Cafe')).queryByRole('button')).not.toBeInTheDocument();
expect(within(await rowOf('Museum')).getByText('Mark visited')).toBeInTheDocument();
unmount();
card({ canMark: false });
await screen.findAllByTestId('walk-station');
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
});
it('on a day put on the plan offline, says the stops wait for the sync and offers no buttons', async () => {
card({ assignment: { id: -1700000000000, day_id: 7 } });
await screen.findAllByTestId('walk-station');
expect(
screen.getByText('This walk was put on the day while offline. Its stops can be checked off once it has synced.')
).toBeInTheDocument();
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
expect(screen.queryByText('Open the walk from a day of the plan to check its stops off for that day.')).toBeNull();
});
it('a mark the server refuses is taken back and said in a toast', async () => {
vi.spyOn(tourVisitRepo, 'update').mockRejectedValue(new Error('No permission'));
card();
fireEvent.click(within(await rowOf('Museum')).getByText('Mark visited'));
expect(
await screen.findByText('The stop could not be marked. Open the walk again and try once more.')
).toBeInTheDocument();
expect(within(await rowOf('Museum')).getByText('Mark visited')).toBeInTheDocument();
});
it('heads the stops with how many were visited on that day', async () => {
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'skipped' } } });
card();
expect(await screen.findByText('1 of 3 visited')).toBeInTheDocument();
});
it('on the day it was opened from, shows each arrival, the end of each stay and what the opening hours say', async () => {
card(onMonday());
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, .+, start 09:00$/);
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument();
const museum = await rowOf('Museum');
expect(within(museum).getByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument();
expect(within(museum).getByTestId('walk-station-conflict')).toHaveTextContent(
'Museum closes at 09:20, during the stay from 09:00 to 09:30 (open 09:00 to 09:20)'
);
expect(within(museum).getByTestId('walk-hours-source')).toHaveTextContent('OpenStreetMap');
const unnamed = await rowOf('Stop 2');
expect(within(unnamed).getByText('Arrival about 09:40')).toBeInTheDocument();
expect(within(unnamed).getByText('Opening hours not read here: sunrise-sunset')).toBeInTheDocument();
// The stay at stop 2 is not known, so the cafe's arrival is a lower bound and its hours are not judged.
const cafe = await rowOf('Cafe');
expect(within(cafe).getByText('Arrival 09:50 at the earliest, stay until 10:05 or later')).toBeInTheDocument();
expect(
within(cafe).getByText('Opening hours not checked: a stay before this stop is not entered yet.')
).toBeInTheDocument();
expect(within(cafe).queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
expect(screen.getByText(/Opening hours from OpenStreetMap or the trip's places/)).toBeInTheDocument();
});
it("names a stop by the trip place it stands for and checks the place's own hours: 17:20 to 18:20 against 18:00", async () => {
const week = JSON.stringify(
Array.from({ length: 7 }, (_, day) =>
day < 5 ? { closed: false, open: '10:00', close: '18:00' } : { closed: true }
)
);
seedStore(useTripStore, { places: [buildPlace({ id: 77, name: 'Lenbachhaus', opening_hours: week })] });
vi.mocked(tourRepo.detail).mockImplementation(() =>
detail([
{ lat: 48, lng: 11, role: 'start', sequence: 0, key: 'stop-01', linked_place_id: 77, stay_minutes: 60 },
{ lat: 48.002, lng: 11, role: 'end', sequence: 1, key: 'stop-02', name: 'Cafe', stay_minutes: 0 },
])
);
card(onMonday('17:20'));
const museum = await rowOf('Lenbachhaus');
expect(within(museum).getByText('Arrival about 17:20, stay until 18:20')).toBeInTheDocument();
expect(within(museum).getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)'
);
expect(within(museum).getByTestId('walk-hours-source')).toHaveTextContent('Trip place');
expect(within(museum).getByText('Entered for this place of the trip.')).toBeInTheDocument();
// Only passed, a stop without hours stays unknown: no window and nothing said to be open.
const cafe = await rowOf('Cafe');
expect(within(cafe).getByText('Arrival about 18:40')).toBeInTheDocument();
expect(within(cafe).queryByText(/Open/)).not.toBeInTheDocument();
});
it('shows the times in the 12 hour format where the user chose it', async () => {
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
card(onMonday());
expect(within(await rowOf('Museum')).getByText('Arrival about 9:00 AM, stay until 9:30 AM')).toBeInTheDocument();
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, .+, start 9:00 AM$/);
});
it('on a day without a start, asks for one and leaves arrivals and hours unsaid', async () => {
card(onMonday(null));
expect(
await screen.findByText('Give the walk a time on this day to see arrivals and opening hours.')
).toBeInTheDocument();
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, .+, no start time$/);
await screen.findAllByTestId('walk-station');
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
expect(screen.queryByText(/Opening hours from OpenStreetMap/)).not.toBeInTheDocument();
});
it('on a day without a date, says so and shows no arrival', async () => {
card(onMonday('09:00', buildDay({ id: 7, trip_id: 4, date: null })));
expect(
await screen.findByText('This day has no date, so arrivals and opening hours are left out.')
).toBeInTheDocument();
await screen.findAllByTestId('walk-station');
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
});
it('without a route, shows no arrival', async () => {
card({ ...onMonday(), route: null });
await screen.findAllByTestId('walk-station');
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
});
it('names no source of hours when no stop has any', async () => {
vi.mocked(tourRepo.detail).mockImplementation(() =>
detail(waypoints.map(({ opening_hours: _hours, ...point }) => point))
);
card(onMonday());
expect(within(await rowOf('Museum')).getByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument();
expect(screen.queryByText(/Opening hours from OpenStreetMap/)).not.toBeInTheDocument();
expect(screen.queryByTestId('walk-hours-source')).not.toBeInTheDocument();
});
it('opened from no day of the walk, takes the first with a start time, lets another be picked, and checks nothing off', async () => {
card({
assignment: null,
days: [MONDAY, TUESDAY],
assignments: { '7': [entry(7, null)], '8': [entry(8, null, '10:00')] },
route: ROUTE,
});
const picker = screen.getByTestId('walk-day-picker');
expect(picker).toHaveTextContent(/^Day 2, .+, start 10:00$/);
// Tuesday from 10:00: the museum closed at 09:20 already.
expect(within(await rowOf('Museum')).getByTestId('walk-station-conflict')).toHaveTextContent(
'Museum is closed at 10:00, the stay would last until 10:30; open 09:00 to 09:20'
);
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
expect(
screen.getByText('Open the walk from a day of the plan to check its stops off for that day.')
).toBeInTheDocument();
fireEvent.click(picker);
const monday = screen.getByRole('button', { name: /^Day 1, .*no start time$/ });
fireEvent.click(monday);
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent(/^Day 1, .+, no start time$/);
expect(screen.getByText('Give the walk a time on this day to see arrivals and opening hours.')).toBeInTheDocument();
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
});
it('opened from no day while the walk is on none, asks to put it on one', async () => {
card({ assignment: null });
expect(
await screen.findByText('Put the walk on a day with a start time to see arrivals and opening hours.')
).toBeInTheDocument();
});
it('reads the stops again once the walk was saved, keeping the ones shown until then', async () => {
const view = render(<WalkStationsCard tour={{ ...walk, revision: 1 }} tripId={4} assignment={ON_DAY} canMark />);
await screen.findAllByTestId('walk-station');
vi.mocked(tourRepo.detail).mockImplementation(() => detail(waypoints.slice(0, 2)));
view.rerender(<WalkStationsCard tour={{ ...walk, revision: 2 }} tripId={4} assignment={ON_DAY} canMark />);
expect(screen.getAllByTestId('walk-station')).toHaveLength(3);
await vi.waitFor(() => expect(screen.getAllByTestId('walk-station')).toHaveLength(1));
expect(tourRepo.detail).toHaveBeenCalledTimes(2);
});
it('a walk whose stops cannot be read lists none, counts none and says so', async () => {
vi.mocked(tourRepo.detail).mockRejectedValue(new Error('Tour not available offline'));
card();
expect(await screen.findByText(UNREAD)).toBeInTheDocument();
expect(screen.queryAllByTestId('walk-station')).toEqual([]);
expect(screen.queryByText(/visited/)).not.toBeInTheDocument();
});
it("offline, the route's two ends read in place of the stops are no stops: no count, and it says so", async () => {
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'visited' } } });
const ends: TourWaypoint[] = [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.002, lng: 11, role: 'end', sequence: 1 },
];
vi.mocked(tourRepo.detail).mockImplementation(() => detail(ends));
card(onMonday());
expect(await screen.findByText(UNREAD)).toBeInTheDocument();
expect(screen.queryAllByTestId('walk-station')).toEqual([]);
expect(screen.queryByText(/visited/)).not.toBeInTheDocument();
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
});
it('a walk saved offline lists its stops before the sync gave them keys', async () => {
const unsaved = waypoints.map(({ key: _key, ...point }) => point);
vi.mocked(tourRepo.detail).mockImplementation(() => detail(unsaved));
card({ tour: { ...walk, place_id: -1700000000001 }, assignment: { id: -1700000000000, day_id: 7 } });
expect(await screen.findAllByTestId('walk-station')).toHaveLength(3);
expect(screen.getByText('0 of 3 visited')).toBeInTheDocument();
expect(screen.queryByText(UNREAD)).not.toBeInTheDocument();
});
it('a walk without stops lists none and misses nothing', async () => {
vi.mocked(tourRepo.detail).mockImplementation(() => detail([]));
card({ tour: { ...walk, station_count: 0 } });
await vi.waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
expect(screen.queryAllByTestId('walk-station')).toEqual([]);
expect(screen.queryByText(UNREAD)).not.toBeInTheDocument();
expect(screen.queryByText(/visited/)).not.toBeInTheDocument();
});
});
describe('WalkProgressPill', () => {
it('says nothing for a hike, a walk without stops, or a day without marks', () => {
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited' } } });
render(
<>
<WalkProgressPill tour={{ ...walk, tour_type: 'hike' }} assignmentId={9} />
<WalkProgressPill tour={{ ...walk, station_count: 0 }} assignmentId={9} />
<WalkProgressPill tour={walk} assignmentId={10} />
<WalkProgressPill tour={walk} assignmentId={undefined} />
</>
);
expect(screen.queryByTestId('plan-walk-progress')).not.toBeInTheDocument();
});
it('shows the visited stops of all of them, named in full', () => {
useTourVisitStore.setState({
byAssignment: { 9: { a1b2c3: 'visited', d4e5f6: 'visited', g7h8i9: 'skipped' }, 10: { a1b2c3: 'skipped' } },
});
render(
<>
<WalkProgressPill tour={{ ...walk, station_count: 5 }} assignmentId={9} />
<WalkProgressPill tour={{ ...walk, station_count: 5 }} assignmentId={10} />
</>
);
const [day9, day10] = screen.getAllByTestId('plan-walk-progress');
expect(day9).toHaveTextContent('2/5');
expect(day9).toHaveAccessibleName('2 of 5 visited');
expect(day10).toHaveTextContent('0/5');
});
});
@@ -0,0 +1,253 @@
import type { TourListItem, TourVisitStatus } from '@trek/shared';
import { Check, CheckCheck, Redo2, SkipForward, Undo2 } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import type { Assignment, AssignmentsMap, Day } from '../../../types';
import { SoftPill } from '../../Planner/planParts';
import { DialogSection, fs } from '../../shared/DialogShell';
import { useToast } from '../../shared/Toast';
import { stationMarkNote, stationRingClass } from '../planner/stationMarks';
import { StationPills, StationShield } from '../planner/stationParts';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { TourDayRowPill } from '../TourDayRowFacts';
import { CardAction } from '../tourParts';
import { StationScheduleLine } from './StationSchedule';
import { useWalkDay } from './useWalkDay';
import { useLinkedPlaceOf, useWalkSchedule } from './useWalkSchedule';
import { useWalkStations } from './useWalkStations';
import { WalkDayLine } from './WalkDayLine';
import { useDayVisits, useWalkProgress } from './walkProgress';
import type { StationSchedule } from './walkSchedule';
const NO_DAYS: Day[] = [];
const NO_ASSIGNMENTS: AssignmentsMap = {};
/**
* CW-03: a walk's progress on one of its days, as a pill of the day row:
* how many of its stations were visited. Nothing is shown before any is.
*/
export function WalkProgressPill({ tour, assignmentId }: { tour: TourListItem; assignmentId: number | undefined }) {
const { t } = useTranslation();
const progress = useWalkProgress(tour, assignmentId);
if (!progress) return null;
return (
<TourDayRowPill
icon={CheckCheck}
value={`${progress.visited}/${progress.total}`}
label={t('tours.walk.progress', progress)}
testId="plan-walk-progress"
/>
);
}
/**
* Whether the stations can be checked off from here: not without a day or the
* right to plan it, and not yet on a day put on the plan offline, which the
* server does not know until it synced.
*/
function markState(canMark: boolean, assignmentId: number | null): 'none' | 'ready' | 'notSynced' {
if (!canMark || assignmentId === null) return 'none';
return assignmentId < 0 ? 'notSynced' : 'ready';
}
function StatusActions({
status,
onMark,
}: {
status: TourVisitStatus | undefined;
onMark: (status: TourVisitStatus | 'planned') => void;
}) {
const { t } = useTranslation();
if (status)
return (
<CardAction icon={<Undo2 size={12} strokeWidth={2} />} onClick={() => onMark('planned')}>
{t('tours.walk.visit.undo')}
</CardAction>
);
return (
<span className="flex flex-wrap gap-1.5">
<CardAction icon={<Check size={12} strokeWidth={2.2} />} onClick={() => onMark('visited')}>
{t('tours.walk.visit.visited')}
</CardAction>
<CardAction icon={<SkipForward size={12} strokeWidth={2} />} onClick={() => onMark('skipped')}>
{t('tours.walk.visit.skip')}
</CardAction>
</span>
);
}
/**
* One station of the list: its number or mark, its name (its own, else the
* trip place it stands for), stay, the day's arrival and opening hours, and
* what can be marked.
*/
function StationRow({
station,
index,
status,
placeName,
schedule,
onMark,
}: {
station: TourPlannerWaypoint;
index: number;
status: TourVisitStatus | undefined;
placeName: string | undefined;
schedule: StationSchedule | undefined;
onMark: ((status: TourVisitStatus | 'planned') => void) | null;
}) {
const { t } = useTranslation();
const name = station.name ?? placeName ?? t('tours.walk.stationNumbered', { number: index + 1 });
return (
<li
data-testid="walk-station"
className={`flex items-start gap-2.5 rounded-[10px] px-1.5 py-1.5 ${status ? 'opacity-70' : ''}`}
>
{/* How much the station matters rings its number; a protection is a shield at its corner (CW-03, CW-10). */}
<span
className={`relative grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-tertiary font-geist tabular-nums text-content-secondary ${stationRingClass(station.importance)}`}
style={fs(11)}
title={stationMarkNote(station, t) ?? undefined}
>
{status === 'visited' ? (
<Check size={12} strokeWidth={2.4} />
) : status === 'skipped' ? (
<Redo2 size={12} strokeWidth={2} />
) : (
index + 1
)}
{station.protected && <StationShield />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span
className={`truncate font-semibold text-content ${status === 'skipped' ? 'line-through' : ''}`}
style={fs(12.5, 'body')}
>
{name}
</span>
<StationPills point={station} />
<StationScheduleLine schedule={schedule} name={name} />
{status && (
<span className="text-content-faint" style={fs(11, 'body')}>
{t(status === 'visited' ? 'tours.walk.visit.isVisited' : 'tours.walk.visit.isSkipped')}
</span>
)}
</span>
{onMark && <StatusActions status={status} onMark={onMark} />}
</li>
);
}
/** The lines above the stations: loading, stations that could not be read, where to check off. */
function WalkStationsNotes({
loading,
unread,
any,
fromDay,
notSynced,
}: {
loading: boolean;
unread: boolean;
any: boolean;
fromDay: boolean;
notSynced: boolean;
}) {
const { t } = useTranslation();
const quiet = (text: string) => (
<span className="text-content-faint" style={fs(11, 'body')}>
{text}
</span>
);
if (loading) return quiet(t('common.loading'));
return (
<>
{unread && quiet(t('tours.walk.stationsUnread'))}
{any && fromDay && quiet(t('tours.walk.visit.fromDay'))}
{any && notSynced && quiet(t('tours.walk.visit.notSynced'))}
</>
);
}
/**
* CW-03, CW-04: a city walk's stations in its detail, in order, each with its
* stay, importance and protection, and its arrival and opening hours on the
* day looked at. Opened from one of the walk's days, that day holds, and each
* station can be marked visited or skipped for it and taken back; opened
* otherwise, a day of the walk can be picked for the times and nothing is
* checked off. Neither changes a route, place or order.
*/
export function WalkStationsCard({
tour,
tripId,
assignment,
canMark,
days = NO_DAYS,
assignments = NO_ASSIGNMENTS,
route = null,
}: {
tour: TourListItem;
tripId: number | string;
/** The walk's entry on the day the dialog was opened from; without one there is nothing to check off. */
assignment: Pick<Assignment, 'id' | 'day_id'> | null;
canMark: boolean;
days?: readonly Day[];
assignments?: AssignmentsMap;
/** The walk's line, along which its walking time is shared out. */
route?: ReadonlyArray<readonly [number, number]> | null;
}) {
const { t } = useTranslation();
const toast = useToast();
const isWalk = tour.tour_type === 'walk';
const stations = useWalkStations(tripId, tour.place_id, isWalk, tour.revision);
const assignmentId = assignment?.id ?? null;
const visits = useDayVisits(assignmentId);
const mark = useTourVisitStore((state) => state.mark);
const placeOf = useLinkedPlaceOf();
const choice = useWalkDay(isWalk ? tour.place_id : null, days, assignments, assignment?.day_id ?? null);
const walkingMinutes = tour.duration ?? null;
const schedule = useWalkSchedule({ stations, route, walkingMinutes, day: choice.day, placeOf });
if (!isWalk) return null;
const onMark = (key: string, status: TourVisitStatus | 'planned') => {
if (assignmentId === null) return;
mark(tripId, assignmentId, key, status).catch(() => toast.error(t('tours.walk.visit.failed')));
};
const listed = stations ?? [];
const visited = listed.filter((station) => station.key && visits?.[station.key] === 'visited').length;
const marking = markState(canMark, assignmentId);
return (
<DialogSection
className="flex-none"
label={t('tours.walk.stations')}
action={
// No count where the stations could not be read: it would contradict the day row's.
assignmentId !== null && listed.length > 0 ? (
<SoftPill>{t('tours.walk.progress', { visited, total: listed.length })}</SoftPill>
) : undefined
}
>
<div data-testid="walk-stations" className="flex flex-col gap-2">
<WalkDayLine choice={choice} schedule={schedule} stationCount={listed.length} />
<WalkStationsNotes
loading={stations === null}
unread={stations?.length === 0 && tour.station_count !== 0}
any={listed.length > 0}
fromDay={assignmentId === null}
notSynced={marking === 'notSynced'}
/>
<ol className="m-0 flex list-none flex-col gap-1.5 p-0">
{listed.map((station, index) => (
<StationRow
key={station.id}
station={station}
index={index}
status={station.key ? visits?.[station.key] : undefined}
placeName={placeOf(station)?.name}
schedule={schedule.byStation.get(station.id)}
onMark={marking === 'ready' && station.key ? (next) => onMark(station.key ?? '', next) : null}
/>
))}
</ol>
</div>
</DialogSection>
);
}
@@ -0,0 +1,60 @@
import { AlertTriangle, Footprints } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatClockTime } from '../../../utils/formatters';
import { fs } from '../../shared/DialogShell';
import { TourNotice } from '../tourParts';
import type { WalkLate, WalkTransitContext } from './walkTransit';
function useIs12h(): boolean {
return useSettingsStore((state) => state.settings.time_format) === '12h';
}
/**
* CW-08: what the search between two city walks goes by, above it: when the
* first walk ends, at the earliest where a time of it is not known, that it
* ends after midnight, or that the end is not known, and then a time is to be
* chosen; and when the next one starts.
*/
export function WalkTransitNote({ walk }: { walk: WalkTransitContext | null }) {
const { t } = useTranslation();
const is12h = useIs12h();
if (!walk) return null;
const params = { walk: walk.fromWalk, time: formatClockTime(walk.ends, is12h) };
let end = t('tours.walk.transit.ends', params);
if (walk.endsNextDay) end = t('tours.walk.transit.endsNextDay', params);
else if (!walk.ends) end = t('tours.walk.transit.endUnknown', params);
else if (walk.endsAtLeast) end = t('tours.walk.transit.endsAtLeast', params);
return (
<TourNotice role="status" icon={<Footprints size={13} strokeWidth={2} />}>
<div data-testid="transit-walk-end">{end}</div>
{walk.nextStarts && (
<div data-testid="transit-walk-next">
{t('tours.walk.transit.nextStarts', { walk: walk.toWalk, time: formatClockTime(walk.nextStarts, is12h) })}
</div>
)}
</TourNotice>
);
}
/**
* CW-08: a connection, or a journey saved before, that arrives after the next
* walk starts. Said on one line that wraps; nothing is moved or replaced.
*/
export function WalkLateLine({ late }: { late?: WalkLate | null }) {
const { t } = useTranslation();
const is12h = useIs12h();
if (!late) return null;
return (
<div
data-testid="transit-walk-late"
className="mt-1.5 flex items-start gap-1 text-warning"
style={fs(11.5, 'body')}
>
<AlertTriangle size={12} strokeWidth={2.2} aria-hidden="true" className="mt-[2px] flex-none" />
<span className="min-w-0 break-words">
{t('tours.walk.transit.late', { walk: late.walk, time: formatClockTime(late.time, is12h) })}
</span>
</div>
);
}
@@ -0,0 +1,126 @@
// CW-03: the visits of a trip's city walks, read when the trip opens and when
// the connection changes, and kept current by its socket through the store.
import { act, renderHook } from '@testing-library/react';
import type { TourVisit } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { addListener, removeListener } from '../../../api/websocket';
import { tourVisitRepo } from '../../../repo/tourVisitRepo';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import { setForcedOffline } from '../../../sync/networkMode';
import { useTourVisitsSync } from './useTourVisitsSync';
const visit = (assignment_id: number, station_key: string): TourVisit => ({
assignment_id,
station_key,
status: 'visited',
});
function later<T>() {
let settle: (value: T) => void = () => undefined;
let fail: (reason: unknown) => void = () => undefined;
const promise = new Promise<T>((resolve, reject) => {
settle = resolve;
fail = reject;
});
return { promise, settle, fail };
}
beforeEach(() => {
useTourVisitStore.setState({ tripId: null, byAssignment: {} });
vi.mocked(addListener).mockClear();
vi.mocked(removeListener).mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
act(() => setForcedOffline(false));
});
describe('useTourVisitsSync', () => {
it('reads nothing and listens to nothing while off, without a trip, or for a trip id that is no number', () => {
const list = vi.spyOn(tourVisitRepo, 'list');
renderHook(() => useTourVisitsSync(4, false));
renderHook(() => useTourVisitsSync(undefined, true));
renderHook(() => useTourVisitsSync('abc', true));
expect(list).not.toHaveBeenCalled();
expect(addListener).not.toHaveBeenCalled();
});
it("reads the trip's visits and listens to its socket, once", async () => {
const list = vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({ visits: [visit(9, 'stop-01')] });
renderHook(() => useTourVisitsSync(4, true));
await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-01': 'visited' } }));
expect(list).toHaveBeenCalledExactlyOnceWith(4);
expect(addListener).toHaveBeenCalledOnce();
});
it('reads them again when the connection goes or comes back, without listening twice', async () => {
const list = vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({ visits: [] });
renderHook(() => useTourVisitsSync(4, true));
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(1));
act(() => setForcedOffline(true));
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(2));
act(() => setForcedOffline(false));
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(3));
expect(addListener).toHaveBeenCalledOnce();
expect(removeListener).not.toHaveBeenCalled();
});
it('a read that fails forgets the visits, but not once the hook is gone', async () => {
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-01': 'visited' } } });
const answer = later<{ visits: TourVisit[] }>();
vi.spyOn(tourVisitRepo, 'list')
.mockRejectedValueOnce(new Error('No permission'))
.mockReturnValueOnce(answer.promise);
const first = renderHook(() => useTourVisitsSync(4, true));
await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({}));
first.unmount();
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-01': 'visited' } } });
const second = renderHook(() => useTourVisitsSync(4, true));
second.unmount();
answer.fail(new Error('Gone'));
await expect(answer.promise).rejects.toThrow('Gone');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-01': 'visited' } });
});
it('stops listening when it goes, and listens to the next trip when the trip changes', () => {
vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({ visits: [] });
const { rerender, unmount } = renderHook(({ trip }) => useTourVisitsSync(trip, true), {
initialProps: { trip: 4 },
});
const [[firstListener]] = vi.mocked(addListener).mock.calls;
rerender({ trip: 5 });
expect(removeListener).toHaveBeenCalledWith(firstListener);
expect(addListener).toHaveBeenCalledTimes(2);
const secondListener = vi.mocked(addListener).mock.calls[1][0];
unmount();
expect(removeListener).toHaveBeenLastCalledWith(secondListener);
});
it('a read for the trip left before answers too late to show', async () => {
const left = later<{ visits: TourVisit[] }>();
vi.spyOn(tourVisitRepo, 'list')
.mockReturnValueOnce(left.promise)
.mockResolvedValueOnce({ visits: [visit(30, 'stop-02')] });
const { rerender } = renderHook(({ trip }) => useTourVisitsSync(trip, true), { initialProps: { trip: 4 } });
rerender({ trip: 5 });
await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({ 30: { 'stop-02': 'visited' } }));
left.settle({ visits: [visit(9, 'stop-01')] });
await left.promise;
expect(useTourVisitStore.getState()).toMatchObject({ tripId: '5', byAssignment: { 30: { 'stop-02': 'visited' } } });
});
});
@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { useTourVisitStore, watchTourVisits } from '../../../store/tourVisitStore';
/**
* CW-03: the visits of a trip's city walks, read when the trip opens and again
* when the connection comes and goes, and kept current by its socket, so a
* station another traveller checks off shows on every day plan at once. Once a
* reconnect has sent the waiting marks, the sync reads them once more
* (reloadTourVisits). Offline they read from this device, with the marks made
* here still waiting to sync.
*/
export function useTourVisitsSync(tripId: number | string | undefined, enabled: boolean) {
const { offline } = useNetworkMode();
const id = enabled && tripId !== undefined && !Number.isNaN(Number(tripId)) ? tripId : null;
useEffect(() => {
if (id === null) return;
let current = true;
useTourVisitStore
.getState()
.load(id)
.catch(() => {
// Refused, or no copy on this device: the day plan shows no progress rather than another trip's.
if (current) useTourVisitStore.getState().reset(id);
});
return () => {
current = false;
};
}, [id, offline]);
useEffect(() => {
if (id === null) return;
return watchTourVisits(id);
}, [id]);
}
@@ -0,0 +1,80 @@
// CW-02, CW-04: the day a city walk's times are shown for. Opened from one of
// its days that day holds; otherwise the first with a start time, or the one
// picked, and another walk starts over.
import { describe, expect, it } from 'vitest';
import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories';
import { act, renderHook } from '../../../../tests/helpers/render';
import type { AssignmentsMap, Day } from '../../../types';
import { useWalkDay } from './useWalkDay';
const one = buildDay({ id: 1, date: '2026-10-19' });
const two = buildDay({ id: 2, date: '2026-10-20' });
const three = buildDay({ id: 3, date: '2026-10-21' });
const days: Day[] = [one, two, three];
const entry = (dayId: number, placeId: number, time: string | null, placeTime: string | null = null) =>
buildAssignment({
day_id: dayId,
place_id: placeId,
assignment_time: time,
place: buildPlace({ id: placeId, place_time: placeTime }),
});
// Walk 42 is on day 1 without a time, on day 2 at the place's own 10:00 (overridden to 9:30 PM there)
// and on day 3 at its place time; another place is on day 1 too.
const assignments: AssignmentsMap = {
'1': [entry(1, 7, '08:00'), entry(1, 42, null)],
'2': [entry(2, 42, '9:30 PM', '10:00')],
'3': [entry(3, 42, null, '11:15')],
};
function walkDay(placeId: number | null, fixedDayId: number | null = null, map: AssignmentsMap = assignments) {
return renderHook(({ id, fixed, entries }) => useWalkDay(id, days, entries, fixed), {
initialProps: { id: placeId, fixed: fixedDayId, entries: map },
});
}
describe('useWalkDay', () => {
it("lists the walk's days in the trip's order with their start, the day's own time before the place's", () => {
const { result } = walkDay(42);
expect(result.current.options.map((option) => [option.day.id, option.index, option.start])).toEqual([
[1, 0, null],
[2, 1, '21:30'],
[3, 2, '11:15'],
]);
});
it('takes the first day with a start time, and the first day where none has one', () => {
expect(walkDay(42).result.current).toMatchObject({ fixed: false, day: { day: { id: 2 } } });
const untimed = { '3': [entry(3, 42, null)], '1': [entry(1, 42, 'later')] };
expect(walkDay(42, null, untimed).result.current.day?.day.id).toBe(1);
});
it('keeps the day picked, falls back once the walk left it, and starts over for another walk', () => {
const view = walkDay(42);
act(() => view.result.current.choose(3));
expect(view.result.current.day?.day.id).toBe(3);
view.rerender({ id: 42, fixed: null, entries: { ...assignments, '3': [] } });
expect(view.result.current.day?.day.id).toBe(2);
const other = { '2': [entry(2, 43, '07:00')], '3': [entry(3, 43, '08:00')] };
view.rerender({ id: 43, fixed: null, entries: other });
expect(view.result.current.day?.day.id).toBe(2);
});
it('holds the day it was opened from, with or without a time, and never another once the walk left it', () => {
expect(walkDay(42, 1).result.current).toMatchObject({ fixed: true, day: { day: { id: 1 }, start: null } });
const gone = walkDay(42, 1, { '2': assignments['2'] ?? [] });
expect(gone.result.current).toMatchObject({ fixed: true, day: null });
act(() => gone.result.current.choose(2));
expect(gone.result.current.day).toBeNull();
});
it('has no day for no walk, and nothing to pick', () => {
const { result } = walkDay(null);
expect(result.current).toMatchObject({ options: [], day: null, fixed: false });
act(() => result.current.choose(2));
expect(result.current.day).toBeNull();
});
});
@@ -0,0 +1,65 @@
import { clockOf } from '@trek/shared';
import { useMemo, useState } from 'react';
import type { AssignmentsMap, Day } from '../../../types';
import { walkStartOf } from './walkSchedule';
/** A day the walk is on, with its place in the trip and the walk's start that day. */
export interface WalkDayOption {
day: Day;
/** The day's position in the trip, so `index + 1` is the number the plan shows. */
index: number;
/** `HH:MM` the walk starts that day; null when none is set or it cannot be read. */
start: string | null;
}
/**
* CW-02: the days a walk is on, in the order of the trip, each with its start
* on that day (`walkStartOf`). A time that cannot be read is no start.
*/
function walkDayOptions(placeId: number, days: readonly Day[], assignments: AssignmentsMap): WalkDayOption[] {
return days.flatMap((day, index) => {
const assignment = (assignments[String(day.id)] ?? []).find((entry) => entry.place_id === placeId);
if (!assignment) return [];
const minutes = walkStartOf(assignment);
return [{ day, index, start: minutes === null ? null : clockOf(minutes) }];
});
}
/** The day the times are worked out for, and how it is picked. */
export interface WalkDayChoice {
options: WalkDayOption[];
day: WalkDayOption | null;
/** Opened from one of the walk's days: that day holds and cannot be switched. */
fixed: boolean;
choose: (dayId: number) => void;
}
/**
* CW-02, CW-04: the day a walk's arrivals and opening hours are shown for.
* Opened from one of its days, that day holds, with or without a time, and is
* gone rather than replaced once the walk leaves it: another day never takes
* over its time quietly. Otherwise the day picked here, or the first with a
* start time, else the first. The choice is only what is looked at, never
* saved, and starts over for another walk.
*/
export function useWalkDay(
placeId: number | null,
days: readonly Day[],
assignments: AssignmentsMap,
fixedDayId: number | null
): WalkDayChoice {
const [chosen, setChosen] = useState<{ placeId: number; dayId: number } | null>(null);
const options = useMemo(
() => (placeId === null ? [] : walkDayOptions(placeId, days, assignments)),
[assignments, days, placeId]
);
const choose = (dayId: number) => {
if (placeId !== null) setChosen({ placeId, dayId });
};
if (fixedDayId !== null) {
return { options, day: options.find((option) => option.day.id === fixedDayId) ?? null, fixed: true, choose };
}
const picked = chosen?.placeId === placeId ? options.find((option) => option.day.id === chosen.dayId) : undefined;
const day = picked ?? options.find((option) => option.start !== null) ?? options[0] ?? null;
return { options, day, fixed: false, choose };
}
@@ -0,0 +1,191 @@
import { act, renderHook } from '@testing-library/react';
import type { WalkImportPreviewResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourWalkImportRepo } from '../../../repo/tourWalkImportRepo';
import { useWalkImport } from './useWalkImport';
// FE-WALK-IMPORT-001 to FE-WALK-IMPORT-007
const ROUTE = 'https://www.google.com/maps/dir/Cathedral/Market/Park/data=!3e2';
const PREVIEW: WalkImportPreviewResponse = {
stations: [
{ name: 'Cathedral', lat: 48.1, lng: 11.5 },
{ name: 'Market', lat: null, lng: null },
{ name: null, lat: 48.12, lng: 11.52 },
{ name: 'Park', lat: 48.13, lng: 11.53 },
],
resolved_url: ROUTE,
};
let preview: ReturnType<typeof vi.spyOn>;
const planner = () => ({ tripId: 5, startDerivedTour: vi.fn(() => true) });
function setup(p = planner()) {
const hook = renderHook(() => useWalkImport({ planner: p }), { wrapper: TranslationProvider });
return { ...hook, planner: p };
}
async function readLink(result: ReturnType<typeof setup>['result'], text: string) {
act(() => result.current.show());
act(() => result.current.setUrl(text));
await act(() => result.current.read());
}
beforeEach(() => {
preview = vi.spyOn(tourWalkImportRepo, 'preview').mockResolvedValue(PREVIEW);
});
afterEach(() => vi.restoreAllMocks());
describe('useWalkImport (CW-07)', () => {
it('FE-WALK-IMPORT-001: reads the link and shows its stops, counting the ones without a position', async () => {
const { result } = setup();
expect(result.current.open).toBe(false);
act(() => result.current.show());
expect(result.current.open).toBe(true);
expect(result.current.canRead).toBe(false);
act(() => result.current.setUrl(' https://maps.app.goo.gl/abc '));
expect(result.current.canRead).toBe(true);
await act(() => result.current.read());
expect(preview).toHaveBeenCalledWith(5, 'https://maps.app.goo.gl/abc', expect.any(AbortSignal));
expect(result.current.phase).toBe('preview');
expect(result.current.preview).toBe(PREVIEW);
expect(result.current.unplaced).toBe(1);
expect(result.current.canLoad).toBe(true);
});
it('FE-WALK-IMPORT-002: takes the one link out of the share text of the Google Maps app, and sends anything else as typed', async () => {
const { result } = setup();
await readLink(result, 'Walk through the old town\nhttps://maps.app.goo.gl/abc');
expect(preview).toHaveBeenLastCalledWith(5, 'https://maps.app.goo.gl/abc', expect.any(AbortSignal));
await readLink(result, 'https://maps.app.goo.gl/a https://maps.app.goo.gl/b');
expect(preview).toHaveBeenLastCalledWith(
5,
'https://maps.app.goo.gl/a https://maps.app.goo.gl/b',
expect.any(AbortSignal)
);
preview.mockClear();
act(() => result.current.setUrl(' '));
await act(() => result.current.read());
expect(preview).not.toHaveBeenCalled();
});
it('FE-WALK-IMPORT-003: a refusal is said in the reader’s language, offline and other failures too', async () => {
const { result } = setup();
preview.mockRejectedValueOnce({
response: { status: 400, data: { error: 'Not on foot.', reason: 'not_walking' } },
});
await readLink(result, ROUTE);
expect(result.current.phase).toBe('link');
expect(result.current.failure).toBe(
'That route is not planned on foot. Switch Google Maps to walking and share the route again.'
);
preview.mockRejectedValueOnce(new OnlineOnlyError());
await act(() => result.current.read());
expect(result.current.failure).toBe('Reading a link needs a connection.');
preview.mockRejectedValueOnce(new Error('Network Error'));
await act(() => result.current.read());
expect(result.current.failure).toBe('The link could not be read just now. Try again in a moment.');
// The link stays as typed for another try.
expect(result.current.url).toBe(ROUTE);
});
it('FE-WALK-IMPORT-004: a newer read and closing drop the request before it answers', async () => {
const answers: Array<(value: WalkImportPreviewResponse) => void> = [];
preview.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
const { result } = setup();
act(() => result.current.show());
act(() => result.current.setUrl(ROUTE));
let first: Promise<void> = Promise.resolve();
act(() => {
first = result.current.read();
});
expect(result.current.phase).toBe('reading');
expect(result.current.canRead).toBe(false);
const firstSignal = preview.mock.calls[0][2] as AbortSignal;
let second: Promise<void> = Promise.resolve();
act(() => {
second = result.current.read();
});
expect(firstSignal.aborted).toBe(true);
const secondSignal = preview.mock.calls[1][2] as AbortSignal;
act(() => result.current.close());
expect(secondSignal.aborted).toBe(true);
await act(async () => {
answers.forEach((resolve) => resolve(PREVIEW));
await Promise.all([first, second]);
});
expect(result.current.open).toBe(false);
expect(result.current.preview).toBeNull();
});
it('FE-WALK-IMPORT-005: a failure that comes after closing is not shown, and unmounting drops the request', async () => {
let fail: (reason: unknown) => void = () => undefined;
preview.mockImplementation(() => new Promise((_resolve, reject) => (fail = reject)));
const { result, unmount } = setup();
act(() => result.current.show());
act(() => result.current.setUrl(ROUTE));
let pending: Promise<void> = Promise.resolve();
act(() => {
pending = result.current.read();
});
act(() => result.current.close());
await act(async () => {
fail(new Error('late'));
await pending;
});
expect(result.current.failure).toBeNull();
act(() => result.current.show());
act(() => result.current.setUrl(ROUTE));
act(() => {
void result.current.read();
});
const signal = preview.mock.calls[1][2] as AbortSignal;
unmount();
expect(signal.aborted).toBe(true);
});
it('FE-WALK-IMPORT-006: loads only the stops with a position, in order and with their names, as a walk from Google Maps', async () => {
const { result, planner: p } = setup();
await readLink(result, ROUTE);
act(() => result.current.load());
expect(p.startDerivedTour).toHaveBeenCalledExactlyOnceWith({
name: 'Walk from Google Maps',
difficulty: 2,
points: [
{ lat: 48.1, lng: 11.5, name: 'Cathedral' },
{ lat: 48.12, lng: 11.52, name: null },
{ lat: 48.13, lng: 11.53, name: 'Park' },
],
inPlace: false,
tourType: 'walk',
source: 'google_maps',
});
expect(result.current.open).toBe(false);
});
it('FE-WALK-IMPORT-007: stays open when the planner does not take it, and loads nothing with fewer than two positions', async () => {
const refusing = { tripId: 5, startDerivedTour: vi.fn(() => false) };
const { result } = setup(refusing);
await readLink(result, ROUTE);
act(() => result.current.load());
expect(refusing.startDerivedTour).toHaveBeenCalledTimes(1);
expect(result.current.open).toBe(true);
act(() => result.current.back());
expect(result.current.phase).toBe('link');
expect(result.current.preview).toBeNull();
expect(result.current.url).toBe(ROUTE);
preview.mockResolvedValueOnce({
...PREVIEW,
stations: [PREVIEW.stations[0], PREVIEW.stations[1]],
});
await act(() => result.current.read());
expect(result.current.canLoad).toBe(false);
act(() => result.current.load());
expect(refusing.startDerivedTour).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,132 @@
import type { WalkImportPreviewResponse, WalkImportStation } from '@trek/shared';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourWalkImportRepo, walkImportRefusalOf } from '../../../repo/tourWalkImportRepo';
import type { TourPlannerController } from '../planner/useTourPlanner';
type Phase = 'link' | 'reading' | 'preview';
type Translate = ReturnType<typeof useTranslation>['t'];
/** A stop the link gives a position. */
type PlacedStation = WalkImportStation & { lat: number; lng: number };
const isPlaced = (station: WalkImportStation): station is PlacedStation => station.lat !== null && station.lng !== null;
const WORD = /\s+/;
const HTTPS_LINK = /^https:\/\/\S+$/i;
/**
* The link in what was pasted. The Google Maps app shares a route as its
* title and the link; a text holding exactly one https link among other words
* is taken as that link. Anything else goes to the server as typed.
*/
function linkIn(text: string): string {
const typed = text.trim();
const links = typed.split(WORD).filter((word) => HTTPS_LINK.test(word));
return links.length === 1 ? links[0] : typed;
}
/** What the dialog says when a link was not read, in the reader's language. */
function failureOf(err: unknown, t: Translate): string {
if (err instanceof OnlineOnlyError) return t('tours.walkImport.offline');
const reason = walkImportRefusalOf(err);
return reason ? t(`tours.walkImport.refusal.${reason}`) : t('tours.walkImport.failed');
}
/**
* CW-07: a city walk from a Google Maps walking route. The dialog reads the
* link pasted into it (the server follows a short link and refuses a route not
* on foot), shows the stops in their order, those without a position marked,
* and only on an explicit load hands the positioned stops to the planner as a
* new, unsaved walk with their names, which TREK then routes on foot itself.
* Closing, going back or a failure leaves the planner as it was.
*/
export function useWalkImport({ planner }: { planner: Pick<TourPlannerController, 'tripId' | 'startDerivedTour'> }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState('');
const [phase, setPhase] = useState<Phase>('link');
const [preview, setPreview] = useState<WalkImportPreviewResponse | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const requestRef = useRef<AbortController | null>(null);
// A read still under way when the planner goes is dropped with it.
useEffect(() => () => requestRef.current?.abort(), []);
const stations = preview?.stations ?? [];
const placed = stations.filter(isPlaced);
const reset = () => {
requestRef.current?.abort();
requestRef.current = null;
setPhase('link');
setPreview(null);
setFailure(null);
};
const show = () => {
reset();
setUrl('');
setOpen(true);
};
const close = () => {
reset();
setOpen(false);
};
const read = async () => {
const link = linkIn(url);
if (!link) return;
requestRef.current?.abort();
const request = new AbortController();
requestRef.current = request;
setPhase('reading');
setFailure(null);
try {
const answer = await tourWalkImportRepo.preview(planner.tripId, link, request.signal);
if (request.signal.aborted) return;
setPreview(answer);
setPhase('preview');
} catch (err: unknown) {
if (request.signal.aborted) return;
setFailure(failureOf(err, t));
setPhase('link');
}
};
const load = () => {
if (placed.length < 2) return;
const started = planner.startDerivedTour({
name: t('tours.walkImport.defaultName'),
difficulty: 2,
points: placed.map(({ lat, lng, name }) => ({ lat, lng, name })),
inPlace: false,
tourType: 'walk',
source: 'google_maps',
});
if (started) close();
};
return {
open,
show,
close,
url,
setUrl,
phase,
preview,
failure,
/** How many stops the link leaves without a position; they are left out on load. */
unplaced: stations.length - placed.length,
canRead: phase !== 'reading' && url.trim().length > 0,
canLoad: phase === 'preview' && placed.length >= 2,
read,
/** Back to the link, which stays as typed. */
back: reset,
load,
};
}
export type WalkImport = ReturnType<typeof useWalkImport>;
@@ -0,0 +1,69 @@
import { walkStationsOf } from '@trek/shared';
import { useCallback, useMemo } from 'react';
import { useTripStore } from '../../../store/tripStore';
import type { AssignmentsMap, Day, Place } from '../../../types';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { useWalkDay, type WalkDayChoice } from './useWalkDay';
import { walkScheduleOf, type LinkedPlaceOf, type WalkSchedule, type WalkScheduleInput } from './walkSchedule';
const NO_PLACES: Place[] = [];
const NO_DAYS: Day[] = [];
const NO_ASSIGNMENTS: AssignmentsMap = {};
const NO_STATIONS: TourPlannerWaypoint[] = [];
/** PR1-10: the trip place a point stands for, while the trip still has it. */
export function useLinkedPlaceOf(): (point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>) => Place | null {
const places = useTripStore((state) => state.places) ?? NO_PLACES;
const byId = useMemo(() => new Map(places.map((place) => [place.id, place])), [places]);
return useCallback(
(point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>) =>
point.linkedPlaceId ? (byId.get(point.linkedPlaceId) ?? null) : null,
[byId]
);
}
/** CW-04: `walkScheduleOf`, worked out again only when one of its parts changes. */
export function useWalkSchedule({ stations, route, walkingMinutes, day, placeOf }: WalkScheduleInput): WalkSchedule {
return useMemo(
() => walkScheduleOf({ stations, route, walkingMinutes, day, placeOf }),
[day, placeOf, route, stations, walkingMinutes]
);
}
/** A walk's times in the planner: whether it is one, the day they are for, the schedule and how many stations. */
export interface PlannerWalkSchedule {
enabled: boolean;
choice: WalkDayChoice;
schedule: WalkSchedule;
stationCount: number;
}
/**
* CW-02, CW-04: the planner's walk against the days it is on. The day is the
* one picked here, else the first with a start time; nothing of it is saved.
* Only a route that belongs to the stations as they are now counts, so a stop
* moved since the last routing shows no arrival until the route is planned again.
*/
export function usePlannerWalkSchedule(
planner: Pick<
TourPlannerController,
'tourType' | 'editingPlaceId' | 'waypoints' | 'route' | 'routeCurrent' | 'walkingDurationMinutes'
>,
placeOf: LinkedPlaceOf
): PlannerWalkSchedule {
const days = useTripStore((state) => state.days) ?? NO_DAYS;
const assignments = useTripStore((state) => state.assignments) ?? NO_ASSIGNMENTS;
const { tourType, editingPlaceId, waypoints, route, routeCurrent, walkingDurationMinutes } = planner;
const enabled = tourType === 'walk';
const choice = useWalkDay(enabled ? (editingPlaceId ?? null) : null, days, assignments, null);
const stations = useMemo(() => (enabled ? walkStationsOf(waypoints) : NO_STATIONS), [enabled, waypoints]);
const schedule = useWalkSchedule({
stations,
route: routeCurrent ? (route ?? null) : null,
walkingMinutes: walkingDurationMinutes ?? null,
day: choice.day,
placeOf,
});
return { enabled, choice, schedule, stationCount: stations.length };
}
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { readWalkStations } from './walkStationsRead';
/**
* CW-03: a saved walk's stations in order: null while they load, none where
* they could not be read. Read again when the walk is saved (its revision
* moves on), here or by someone else, so the detail never shows the stations
* of before; the ones shown stay until the new ones are in.
*/
export function useWalkStations(
tripId: number | string,
placeId: number,
enabled: boolean,
revision?: number
): TourPlannerWaypoint[] | null {
const [stations, setStations] = useState<{ placeId: number; points: TourPlannerWaypoint[] } | null>(null);
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
readWalkStations(tripId, placeId, controller.signal)
.then((points) => {
if (!controller.signal.aborted) setStations({ placeId, points });
})
.catch(() => {
if (!controller.signal.aborted) setStations({ placeId, points: [] });
});
return () => controller.abort();
}, [enabled, placeId, revision, tripId]);
return enabled && stations?.placeId === placeId ? stations.points : null;
}
@@ -0,0 +1,44 @@
// CW-03: a city walk's progress on one of its days, read alike by the day rows of the desktop and the phone.
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import { useDayVisits, useWalkProgress, walkProgressOf } from './walkProgress';
const walk = { tour_type: 'walk', station_count: 5 } as const;
beforeEach(() => useTourVisitStore.setState({ tripId: null, byAssignment: {} }));
describe('walkProgressOf', () => {
it('says nothing for a hike, a walk without stops, or a day without marks', () => {
const visits = { 'stop-01': 'visited' } as const;
expect(walkProgressOf({ tour_type: 'hike', station_count: 5 }, visits)).toBeNull();
expect(walkProgressOf({ ...walk, station_count: 0 }, visits)).toBeNull();
expect(walkProgressOf({ ...walk, station_count: null }, visits)).toBeNull();
expect(walkProgressOf({ tour_type: 'walk' }, visits)).toBeNull();
expect(walkProgressOf(walk, undefined)).toBeNull();
expect(walkProgressOf(walk, {})).toBeNull();
});
it('counts the visited stops of all of them, a skip as marked but not visited', () => {
const visits = { 'stop-01': 'visited', 'stop-02': 'visited', 'stop-03': 'skipped' } as const;
expect(walkProgressOf(walk, visits)).toEqual({ visited: 2, total: 5 });
expect(walkProgressOf(walk, { 'stop-03': 'skipped' })).toEqual({ visited: 0, total: 5 });
});
it('never counts more visited stops than the walk has', () => {
const visits = { 'stop-01': 'visited', 'stop-02': 'visited', 'stop-03': 'visited' } as const;
expect(walkProgressOf({ ...walk, station_count: 2 }, visits)).toEqual({ visited: 2, total: 2 });
});
});
describe('useWalkProgress', () => {
it("reads the day's visits from the store, and nothing without a day", () => {
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited' }, 10: { 'stop-02': 'skipped' } } });
expect(renderHook(() => useWalkProgress(walk, 9)).result.current).toEqual({ visited: 1, total: 5 });
expect(renderHook(() => useWalkProgress(walk, 10)).result.current).toEqual({ visited: 0, total: 5 });
expect(renderHook(() => useWalkProgress(walk, 11)).result.current).toBeNull();
expect(renderHook(() => useWalkProgress(walk, undefined)).result.current).toBeNull();
expect(renderHook(() => useDayVisits(null)).result.current).toBeUndefined();
expect(renderHook(() => useDayVisits(9)).result.current).toEqual({ 'stop-01': 'visited' });
});
});
@@ -0,0 +1,32 @@
import type { TourListItem } from '@trek/shared';
import { useTourVisitStore, type VisitsByStation } from '../../../store/tourVisitStore';
/** The day's status of each station of a walk, by its key; undefined before any is marked or without a day. */
export function useDayVisits(assignmentId: number | null | undefined): VisitsByStation | undefined {
return useTourVisitStore((state) => (assignmentId == null ? undefined : state.byAssignment[assignmentId]));
}
/**
* CW-03: how many of a walk's stations were visited on one day, out of all of
* them, or null where there is nothing to say yet: not a walk, no stations, or
* none marked on that day. A skip counts as marked but not as visited.
*/
export function walkProgressOf(
tour: Pick<TourListItem, 'tour_type' | 'station_count'>,
visits: VisitsByStation | undefined
): { visited: number; total: number } | null {
const total = tour.station_count ?? 0;
if (tour.tour_type !== 'walk' || total === 0 || !visits) return null;
const statuses = Object.values(visits);
if (statuses.length === 0) return null;
const visited = statuses.filter((status) => status === 'visited').length;
return { visited: Math.min(visited, total), total };
}
/** A walk's progress on the day its row stands for, shared by the day rows of the desktop and the phone. */
export function useWalkProgress(
tour: Pick<TourListItem, 'tour_type' | 'station_count'>,
assignmentId: number | null | undefined
): { visited: number; total: number } | null {
return walkProgressOf(tour, useDayVisits(assignmentId));
}
@@ -0,0 +1,88 @@
// CW-02, CW-04: a city walk's stations on one day: what is missing for their
// arrivals, and their arrivals, stays and opening hours once nothing is.
import { describe, expect, it } from 'vitest';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { walkScheduleOf, type LinkedPlaceOf, type WalkScheduleInput } from './walkSchedule';
const ROUTE: Array<[number, number]> = [
[48, 11],
[48.001, 11],
[48.002, 11],
];
const museum: TourPlannerWaypoint = {
id: 'museum',
lat: 48,
lng: 11,
role: 'start',
name: 'Museum',
stayMinutes: 90,
openingHours: 'Mo-Fr 10:00-18:00',
};
const square: TourPlannerWaypoint = { id: 'square', lat: 48.001, lng: 11, role: 'via', linkedPlaceId: 5 };
const cafe: TourPlannerWaypoint = { id: 'cafe', lat: 48.002, lng: 11, role: 'end', name: 'Cafe', stayMinutes: 30 };
// 2026-10-19 is a Monday.
const MONDAY = { date: '2026-10-19' };
const noPlace: LinkedPlaceOf = () => null;
function input(overrides: Partial<WalkScheduleInput> = {}): WalkScheduleInput {
return {
stations: [museum, square, cafe],
route: ROUTE,
walkingMinutes: 40,
day: { day: MONDAY, start: '09:30' },
placeOf: noPlace,
...overrides,
};
}
describe('walkScheduleOf', () => {
it('says what is missing, in order: a day, its date, a start time, a route with its walking time', () => {
expect(walkScheduleOf(input({ day: null })).state).toBe('noDay');
expect(walkScheduleOf(input({ day: { day: { date: null }, start: '09:30' } })).state).toBe('noDate');
expect(walkScheduleOf(input({ day: { day: MONDAY, start: null } })).state).toBe('noStart');
expect(walkScheduleOf(input({ day: { day: MONDAY, start: 'soon' } })).state).toBe('noStart');
expect(walkScheduleOf(input({ stations: null })).state).toBe('noRoute');
expect(walkScheduleOf(input({ route: null })).state).toBe('noRoute');
expect(walkScheduleOf(input({ route: ROUTE.slice(0, 1) })).state).toBe('noRoute');
const unknown = walkScheduleOf(input({ walkingMinutes: null }));
expect(unknown).toEqual({ state: 'noRoute', byStation: new Map(), anyHours: false });
});
it('gives each station its arrival, the end of its stay and what its hours say', () => {
const schedule = walkScheduleOf(input());
expect(schedule.state).toBe('ready');
expect(schedule.anyHours).toBe(true);
expect(schedule.byStation.get('museum')).toEqual({
arrival: 570,
atLeast: false,
leave: 660,
fit: { kind: 'closed', source: 'osm', windows: [[600, 1080]] },
});
// No stay entered at the square: its end is not known, and the cafe after it comes at the earliest.
expect(schedule.byStation.get('square')).toEqual({
arrival: 680,
atLeast: false,
leave: null,
fit: { kind: 'unknown' },
});
expect(schedule.byStation.get('cafe')).toMatchObject({ arrival: 700, atLeast: true, leave: 730 });
});
it("reads a start typed in twelve hours, and a trip place's own hours before the station's", () => {
const week = JSON.stringify(Array.from({ length: 7 }, () => ({ closed: false, open: '09:00', close: '23:30' })));
const placeOf: LinkedPlaceOf = (point) =>
point.linkedPlaceId === 5 ? { name: 'Square', opening_hours: week } : null;
const stations = [museum, { ...square, openingHours: 'Mo off' }, cafe];
const schedule = walkScheduleOf(input({ stations, day: { day: MONDAY, start: '9:30 PM' }, placeOf }));
expect(schedule.byStation.get('museum')?.arrival).toBe(1290);
expect(schedule.byStation.get('square')?.fit).toEqual({ kind: 'open', source: 'place', window: [540, 1410] });
});
it('knows no hours where no station has any', () => {
const schedule = walkScheduleOf(input({ stations: [{ ...museum, openingHours: null }, square, cafe] }));
expect(schedule.state).toBe('ready');
expect(schedule.anyHours).toBe(false);
});
});
@@ -0,0 +1,90 @@
import { minutesOf, stationArrivals, stationFit, stationHoursOn, type StationFit } from '@trek/shared';
import type { Assignment, Day, Place } from '../../../types';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
/**
* CW-02, CW-04: a city walk's stations on the day it is looked at: the arrival
* at each, the end of its stay and what its opening hours say, worked out by
* the rules in `@trek/shared` (`walk-hours.ts`). Nothing is said without a
* day, a date, a start time and a route with its walking time, so no estimate
* looks more exact than what it is made of.
*/
/** What a schedule still misses, or `ready`. */
export type WalkScheduleState = 'noDay' | 'noDate' | 'noStart' | 'noRoute' | 'ready';
/** One station on that day: arrival and end of stay in minutes after midnight, and what its hours say. */
export interface StationSchedule {
arrival: number;
/** A stay before it is not known, so the arrival may come later. */
atLeast: boolean;
/** The end of the stay; null while the stay is not known. */
leave: number | null;
fit: StationFit;
}
export interface WalkSchedule {
state: WalkScheduleState;
/** Keyed by the waypoint's id; empty unless `ready`. */
byStation: ReadonlyMap<string, StationSchedule>;
/** Some station has opening hours to go by. */
anyHours: boolean;
}
/** The trip place a waypoint stands for, as far as the hours and the name need it. */
export type LinkedPlaceOf = (
point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>
) => Pick<Place, 'name' | 'opening_hours'> | null;
export interface WalkScheduleInput {
/** The walk's stations in order: no shaping point, no way back of a loop. Null while they load. */
stations: readonly TourPlannerWaypoint[] | null;
route: ReadonlyArray<readonly [number, number]> | null;
walkingMinutes: number | null;
/** The day it is looked at, with the walk's start that day; null when it is on none. */
day: { day: Pick<Day, 'date'>; start: string | null } | null;
placeOf: LinkedPlaceOf;
}
/**
* CW-02, CW-04, CW-08: when a walk starts on a day, in minutes after midnight:
* the time set on that day's entry, else the place's own time, as the plan
* shows it. Null where neither is set or it cannot be read.
*/
export function walkStartOf(assignment: Pick<Assignment, 'assignment_time' | 'place'>): number | null {
return minutesOf(assignment.assignment_time ?? assignment.place?.place_time);
}
function emptySchedule(state: WalkScheduleState): WalkSchedule {
return { state, byStation: new Map(), anyHours: false };
}
/** The date and the start in minutes the arrivals count from, or what is missing for them. */
function startOf(day: WalkScheduleInput['day']): { date: string; start: number } | WalkScheduleState {
if (!day) return 'noDay';
if (!day.day.date) return 'noDate';
const start = minutesOf(day.start);
return start === null ? 'noStart' : { date: day.day.date, start };
}
export function walkScheduleOf({ stations, route, walkingMinutes, day, placeOf }: WalkScheduleInput): WalkSchedule {
const from = startOf(day);
if (typeof from === 'string') return emptySchedule(from);
if (!stations || !route) return emptySchedule('noRoute');
const arrivals = stationArrivals(stations, route, from.start, walkingMinutes);
if (!arrivals) return emptySchedule('noRoute');
const byStation = new Map<string, StationSchedule>();
let anyHours = false;
stations.forEach((station, index) => {
const at = arrivals[index];
const hours = stationHoursOn({ place: placeOf(station)?.opening_hours, osm: station.openingHours }, from.date);
if (hours) anyHours = true;
const stay = station.stayMinutes ?? null;
byStation.set(station.id, {
...at,
leave: stay === null ? null : at.arrival + stay,
fit: stationFit(hours, at, stay),
});
});
return { state: 'ready', byStation, anyHours };
}
@@ -0,0 +1,28 @@
import { walkStationsOf } from '@trek/shared';
import { tourRepo } from '../../../repo/tourRepo';
import { randomId } from '../../../utils/randomId';
import { plannerWaypointsOf, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
/**
* CW-03: a synced walk's stations all carry a key. Points without any are the
* route's ends, which this device reads in their place while it holds none of
* the stations, so they are not listed as the walk's. A walk saved offline has
* no keys before it synced, and its stations stay.
*/
function ownStations(points: TourPlannerWaypoint[], placeId: number): TourPlannerWaypoint[] {
return placeId < 0 || points.some((point) => point.key) ? points : [];
}
/**
* CW-03, CW-08: a saved walk's stations in order, as this device can read
* them: from the server, else from its offline copy. Rejects where neither
* has the walk.
*/
export async function readWalkStations(
tripId: number | string,
placeId: number,
signal?: AbortSignal
): Promise<TourPlannerWaypoint[]> {
const detail = await tourRepo.detail(tripId, placeId, signal);
return ownStations(walkStationsOf(plannerWaypointsOf(detail.waypoints, randomId)), placeId);
}
@@ -0,0 +1,110 @@
// FE-WALK-TIME-FIELDS-001 to FE-WALK-TIME-FIELDS-005: a city walk's walking time, stays and total in one
// wording for the planner and the tour detail (CW-02).
import { describe, expect, it } from 'vitest';
import { walkStaysText, walkTimeFields } from './walkTimeFields';
const t = (key: string, params?: Record<string, unknown>) =>
({
'tours.planner.walkingTimeUnknown': 'Not calculated',
'tours.planner.plannedShort': 'Planned',
'tours.planner.plannedTotalManual': 'Manual override',
'tours.walk.stays': 'Stays',
'tours.walk.station.stayUnknown': 'Not known yet',
'tours.walk.totalComputed': 'Computed',
})[key] ?? (key === 'tours.walk.totalAtLeast' ? `at least ${String(params?.time)}` : key);
const walking = { label: 'Walking' };
const fieldsOf = (
walkingMinutes: number | null,
stays: { stayMinutes: number; unknownStays: number; stations: number },
manualMinutes: number | null = null
) => walkTimeFields({ walking, walkingMinutes, stays, manualMinutes, t }).map((field) => [field.label, field.value]);
describe('walkTimeFields', () => {
it('FE-WALK-TIME-FIELDS-001: 45 min of walking, a museum of 90 min and a cafe of 30 min make 2 h 45 min', () => {
expect(fieldsOf(45, { stayMinutes: 120, unknownStays: 0, stations: 3 })).toEqual([
['Walking', '45 min'],
['Stays', '2 h'],
['Planned', '2 h 45 min'],
]);
});
it('FE-WALK-TIME-FIELDS-002: a stay not entered makes the stays and the total at least what is known', () => {
expect(fieldsOf(45, { stayMinutes: 90, unknownStays: 1, stations: 3 })).toEqual([
['Walking', '45 min'],
['Stays', 'at least 1 h 30 min'],
['Planned', 'at least 2 h 15 min'],
]);
// No stay entered at all plans the walking time alone, with no stays field to call them unknown.
expect(fieldsOf(45, { stayMinutes: 0, unknownStays: 3, stations: 3 })).toEqual([
['Walking', '45 min'],
['Planned', '45 min'],
]);
});
it('FE-WALK-TIME-FIELDS-003: stays all entered as 0 are 0 min, passed through on purpose', () => {
expect(fieldsOf(45, { stayMinutes: 0, unknownStays: 0, stations: 2 })).toEqual([
['Walking', '45 min'],
['Stays', '0 min'],
['Planned', '45 min'],
]);
});
it('FE-WALK-TIME-FIELDS-004: without a walking time the stays entered are a part of the total, and nothing known none', () => {
expect(fieldsOf(null, { stayMinutes: 120, unknownStays: 0, stations: 3 })).toEqual([
['Walking', 'Not calculated'],
['Stays', '2 h'],
['Planned', 'at least 2 h'],
]);
expect(fieldsOf(null, { stayMinutes: 0, unknownStays: 2, stations: 2 })).toEqual([
['Walking', 'Not calculated'],
['Planned', 'Not calculated'],
]);
});
it('FE-WALK-TIME-FIELDS-005: a total entered by hand keeps its label, and the computed one shows beside it', () => {
expect(fieldsOf(45, { stayMinutes: 90, unknownStays: 1, stations: 3 }, 240)).toEqual([
['Walking', '45 min'],
['Stays', 'at least 1 h 30 min'],
['Planned (Manual override)', '4 h'],
['Computed', 'at least 2 h 15 min'],
]);
expect(fieldsOf(null, { stayMinutes: 0, unknownStays: 1, stations: 1 }, 60)[2]).toEqual([
'Computed',
'Not calculated',
]);
// A column too narrow for the full label names the manual total by what sets it apart.
const manual = walkTimeFields({
walking,
walkingMinutes: 45,
stays: { stayMinutes: 0, unknownStays: 0, stations: 2 },
manualMinutes: 240,
t,
})[2];
expect(manual).toMatchObject({ label: 'Planned (Manual override)', shortLabel: 'Manual override' });
});
it('keeps the walking label and its explanation as the planner hands them in', () => {
const [first] = walkTimeFields({
walking: { label: 'Est. walking time', shortLabel: 'Walking', info: { label: 'How', description: 'Router' } },
walkingMinutes: 30,
stays: { stayMinutes: 0, unknownStays: 0, stations: 2 },
manualMinutes: null,
t,
});
expect(first).toEqual({
label: 'Est. walking time',
shortLabel: 'Walking',
info: { label: 'How', description: 'Router' },
value: '30 min',
});
});
});
describe('walkStaysText', () => {
it('says nothing of the stays of a walk without stations, or with none entered', () => {
expect(walkStaysText({ stayMinutes: 0, unknownStays: 0, stations: 0 }, t)).toBeNull();
expect(walkStaysText({ stayMinutes: 0, unknownStays: 2, stations: 2 }, t)).toBeNull();
expect(walkStaysText({ stayMinutes: 30, unknownStays: 1, stations: 2 }, t)).toBe('at least 30 min');
});
});
@@ -0,0 +1,69 @@
import { someStaysUnknown, walkTimesOf, type WalkTimes } from '../planner/stationModel';
import type { TourMetricField } from '../tourParts';
import { formatPlannedTourDuration, plannedTotalText } from '../tourPresentation';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** What a city walk's stays are known of: how many, how many have no stay entered, and the stays entered. */
type WalkStays = Pick<WalkTimes, 'stayMinutes' | 'unknownStays' | 'stations'>;
/**
* A city walk's stays as said: their sum, and at least the sum while some
* stay is not entered yet. Nothing while no stay is entered at all: the walk
* is then planned as its walking time, and saying "unknown" at every turn
* would only be noise. A stop passed through counts as the 0 entered for it,
* so stays all entered as 0 read "0 min".
*/
export function walkStaysText(stays: WalkStays, t: Translate): string | null {
if (stays.unknownStays >= stays.stations) return null;
const time = formatPlannedTourDuration(stays.stayMinutes);
return someStaysUnknown(stays) ? t('tours.walk.totalAtLeast', { time }) : time;
}
export interface WalkTimeFieldsInput {
/** How the walking time is named: with its explanation in the planner, short in the tour detail. */
walking: Omit<TourMetricField, 'value'>;
walkingMinutes: number | null;
stays: WalkStays;
/** A total entered by hand, which stays apart from the one worked out; null when there is none. */
manualMinutes: number | null;
t: Translate;
}
/**
* CW-02: a city walk's times as fields, in one wording for the planner and the
* tour detail: the walking time, the stays, and the planned total of both. A
* total entered by hand keeps its own label, and the total worked out shows
* beside it as computed, so the two never read as one. 45 min of walking, a
* museum of 90 min and a cafe of 30 min read 45 min, 2 h and 2 h 45 min.
*/
export function walkTimeFields({
walking,
walkingMinutes,
stays,
manualMinutes,
t,
}: WalkTimeFieldsInput): TourMetricField[] {
const notCalculated = t('tours.planner.walkingTimeUnknown');
const { stayMinutes, unknownStays, stations } = stays;
const times = walkTimesOf({ walkingMinutes, stayMinutes, unknownStays, stations });
const computed =
plannedTotalText({ plannedTotalMinutes: times.totalMinutes, atLeast: times.partial }, t) ?? notCalculated;
const staysText = walkStaysText(stays, t);
const parts: TourMetricField[] = [
{ ...walking, value: walkingMinutes == null ? notCalculated : formatPlannedTourDuration(walkingMinutes) },
...(staysText ? [{ label: t('tours.walk.stays'), value: staysText }] : []),
];
const planned = t('tours.planner.plannedShort');
if (manualMinutes == null) return [...parts, { label: planned, value: computed }];
return [
...parts,
{
label: `${planned} (${t('tours.planner.plannedTotalManual')})`,
// A narrow column names it by what sets it apart from the computed total beside it.
shortLabel: t('tours.planner.plannedTotalManual'),
value: formatPlannedTourDuration(manualMinutes),
},
{ label: t('tours.walk.totalComputed'), value: computed },
];
}
@@ -0,0 +1,339 @@
import type { TourListItem } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import type { Assignment } from '../../../types';
import type { MergedItem } from '../../../utils/dayMerge';
import type { DayItineraryItem } from '../../Map/dayTourProjection';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { walkStartOf } from './walkSchedule';
import {
arrivesAfterStart,
journeyLate,
lateForWalk,
walkEndOf,
walkPairAt,
walkTransitLeg,
type WalkPair,
type WalkTransitContext,
} from './walkTransit';
// CW-08: public transit between two city walks on one day.
type Spot = { lat: number; lng: number };
function assignment(placeId: number, time: { assignment?: string | null; place?: string | null } = {}): Assignment {
return {
id: placeId + 100,
day_id: 10,
place_id: placeId,
order_index: 0,
tour_place_id: placeId,
assignment_time: time.assignment ?? null,
place: { id: placeId, name: `Place ${placeId}`, place_time: time.place ?? null },
} as unknown as Assignment;
}
function tourItem(
placeId: number,
start: Spot,
end: Spot,
time: { assignment?: string | null; place?: string | null } = {}
): Extract<DayItineraryItem, { kind: 'tour' }> {
const loop = start.lat === end.lat && start.lng === end.lng;
return { kind: 'tour', assignment: assignment(placeId, time), placeId, start, end, geometry: [], loop, valid: true };
}
function tour(placeId: number, name: string, overrides: Partial<TourListItem> = {}): TourListItem {
return {
place_id: placeId,
name,
tour_type: 'walk',
duration: 90,
planned_duration_minutes: null,
break_additional_minutes: null,
station_count: 2,
station_stay_minutes: 45,
station_stays_unknown: 0,
...overrides,
} as TourListItem;
}
const OLD_TOWN = tour(1, 'Old town');
const HARBOUR = tour(2, 'Harbour');
const HIKE = tour(3, 'Ridge', { tour_type: 'hike' });
const tours = new Map([OLD_TOWN, HARBOUR, HIKE].map((item) => [item.place_id, item]));
const tourOf = (placeId: number) => tours.get(placeId);
const A_START = { lat: 52.52, lng: 13.4 };
const A_END = { lat: 52.51, lng: 13.42 };
const B_START = { lat: 52.5, lng: 13.45 };
const B_END = { lat: 52.49, lng: 13.46 };
const leg = (from: Spot, to: Spot) => ({ from: { name: '', ...from }, to: { name: '', ...to }, time: '10:00' });
const stop = (id: string, name?: string, linkedPlaceId?: number): TourPlannerWaypoint => ({
id,
lat: 0,
lng: 0,
role: 'via',
...(name ? { name } : {}),
...(linkedPlaceId ? { linkedPlaceId } : {}),
});
const t = (key: string, params?: Record<string, string | number | null>) => `${key} ${JSON.stringify(params ?? {})}`;
describe('walkPairAt', () => {
const first = tourItem(1, A_START, A_END, { place: '10:00' });
const next = tourItem(2, B_START, B_END, { place: '13:00' });
const located = (placeId: number, at: Spot, extra: Partial<Assignment> = {}): DayItineraryItem => ({
kind: 'stop',
assignment: { ...assignment(placeId), tour_place_id: null, place: { id: placeId, ...at }, ...extra } as Assignment,
});
it('finds the walk the leg leaves at its end and the next one it reaches at its start', () => {
const pair = walkPairAt([first, next], leg(A_END, B_START), tourOf, first.assignment.id);
expect(pair?.first.tour.name).toBe('Old town');
expect(pair?.next.tour.name).toBe('Harbour');
});
it('reads past an item the route does not go to: a stop without a place, out of the route, or a booked night', () => {
const unlocated: DayItineraryItem = { kind: 'stop', assignment: assignment(9) };
const excluded = located(8, A_END, { route_excluded: true });
const night = located(7, A_END, { accommodation_id: 3 });
expect(walkPairAt([first, unlocated, excluded, night, next], leg(A_END, B_START), tourOf, 101)?.next.item).toBe(
next
);
});
it('is no pair where the route goes to a stop first, even one at the walk end or the next walk start', () => {
expect(walkPairAt([first, located(9, A_END), next], leg(A_END, B_START), tourOf, 101)).toBeNull();
expect(walkPairAt([first, located(9, B_START), next], leg(A_END, B_START), tourOf, 101)).toBeNull();
});
it('pairs a leg with the walk it leaves where two walks of the day end at one spot', () => {
// Two loops from the hotel, each followed by a walk that starts at the same square.
const hotel = { lat: 52.53, lng: 13.38 };
const anyWalk = (placeId: number) => tour(placeId, `Walk ${placeId}`);
const morning = tourItem(11, hotel, hotel, { place: '09:00' });
const afternoon = tourItem(13, hotel, hotel, { place: '14:00' });
const items = [morning, tourItem(12, B_START, B_END), afternoon, tourItem(14, B_START, B_END)];
const pair = walkPairAt(items, leg(hotel, B_START), anyWalk, afternoon.assignment.id);
expect(pair?.first.item).toBe(afternoon);
expect(pair?.next.item).toBe(items[3]);
if (!pair) throw new Error('no pair');
const searched = walkTransitLeg({
leg: leg(hotel, B_START),
dayId: 10,
pair,
stops: { first: [], next: [] },
places: [],
t,
});
expect(searched.time).toBe('16:15');
expect(walkPairAt(items, leg(hotel, B_START), anyWalk, morning.assignment.id)?.next.item).toBe(items[1]);
});
it('is no pair for a leg that leaves elsewhere or reaches no walk', () => {
const hike = tourItem(3, B_START, B_END);
const track = tourItem(4, B_START, B_END);
expect(walkPairAt([first, hike], leg(A_END, B_START), tourOf, 101)).toBeNull();
// A place with a track of its own is no tour of the list.
expect(walkPairAt([first, track], leg(A_END, B_START), tourOf, 101)).toBeNull();
expect(walkPairAt([hike, next], leg(B_END, B_START), tourOf, 103)).toBeNull();
// The leg leaves the walk's start, not its end.
expect(walkPairAt([first, next], leg(A_START, B_START), tourOf, 101)).toBeNull();
// A leg from the walk to a place, from a walk with nothing after it, or from no entry of the day.
expect(walkPairAt([first, located(9, { lat: 1, lng: 1 })], leg(A_END, { lat: 1, lng: 1 }), tourOf, 101)).toBeNull();
expect(walkPairAt([first], leg(A_END, B_START), tourOf, 101)).toBeNull();
expect(walkPairAt([first, next], leg(A_END, B_START), tourOf, undefined)).toBeNull();
expect(walkPairAt([first, next], leg(A_END, B_START), tourOf, 102)).toBeNull();
});
});
describe('walkEndOf', () => {
it('adds the walking time and the stays to the start', () => {
expect(walkEndOf(600, OLD_TOWN)).toEqual({ minutes: 735, atLeast: false, nextDay: false });
});
it('is at the earliest while a stay is not known, or the list says nothing of the stops', () => {
expect(walkEndOf(600, tour(1, 'A', { station_stays_unknown: 1 }))).toMatchObject({ minutes: 735, atLeast: true });
expect(walkEndOf(600, tour(1, 'A', { station_stay_minutes: null }))).toMatchObject({ minutes: 690, atLeast: true });
});
it('takes a total entered by hand before the one worked out', () => {
expect(walkEndOf(600, tour(1, 'A', { planned_duration_minutes: 240, station_stays_unknown: 1 }))).toMatchObject({
minutes: 840,
atLeast: false,
});
});
it('is not known without a start or without any time', () => {
expect(walkEndOf(null, OLD_TOWN)).toBeNull();
expect(walkEndOf(600, tour(1, 'A', { duration: null, station_stay_minutes: 0 }))).toBeNull();
});
it('ends on the next day from midnight on', () => {
expect(walkEndOf(23 * 60, OLD_TOWN)).toEqual({ minutes: 1515, atLeast: false, nextDay: true });
expect(walkEndOf(24 * 60 - 135, OLD_TOWN)).toMatchObject({ nextDay: true });
expect(walkEndOf(24 * 60 - 136, OLD_TOWN)).toMatchObject({ nextDay: false });
});
});
describe('walkStartOf', () => {
it('takes the time on the day before the place time, and reads neither when none is set', () => {
expect(walkStartOf(assignment(1, { assignment: '9:05', place: '11:00' }))).toBe(545);
expect(walkStartOf(assignment(1, { place: '11:00' }))).toBe(660);
expect(walkStartOf(assignment(1))).toBeNull();
});
});
describe('walkTransitLeg', () => {
const pair = (firstTime: { place?: string | null } = { place: '10:00' }, loop = false): WalkPair => ({
first: { item: tourItem(1, A_START, loop ? A_START : A_END, firstTime), tour: OLD_TOWN },
next: { item: tourItem(2, B_START, B_END, { assignment: '9:05' }), tour: HARBOUR },
});
const places = [{ id: 7, name: 'Fish market' }];
it('runs from the last stop to the first, departing when the first walk ends', () => {
const result = walkTransitLeg({
leg: leg(A_END, B_START),
dayId: 10,
pair: pair(),
stops: { first: [stop('a', 'Town hall'), stop('b', 'Old bridge')], next: [stop('c', undefined, 7)] },
places,
t,
});
expect(result).toEqual({
from: { name: 'Old bridge', ...A_END },
to: { name: 'Fish market', ...B_START },
time: '12:15',
walk: {
dayId: 10,
fromWalk: 'Old town',
toWalk: 'Harbour',
ends: '12:15',
endsNextDay: false,
endsAtLeast: false,
nextStarts: '09:05',
},
});
});
it('names an end after its walk where the stops are unread or unnamed, and makes up no time', () => {
const result = walkTransitLeg({
leg: leg(A_END, B_START),
dayId: 10,
pair: pair({ place: null }),
stops: { first: null, next: [stop('c')] },
places,
t,
});
expect(result.from.name).toBe('tours.walk.transit.endOf {"walk":"Old town"}');
expect(result.to.name).toBe('tours.walk.transit.startOf {"walk":"Harbour"}');
// The leg's own time is the walk's start, never when it ends.
expect(result.time).toBeNull();
expect(result.walk).toMatchObject({ ends: null, endsNextDay: false, endsAtLeast: false });
});
it('says a walk ending after midnight ends on the next day, and makes up no time on this one', () => {
const result = walkTransitLeg({
leg: leg(A_END, B_START),
dayId: 10,
pair: pair({ place: '23:00' }),
stops: { first: [], next: [] },
places,
t,
});
expect(result.time).toBeNull();
expect(result.walk).toMatchObject({ ends: null, endsNextDay: true });
});
it('says nothing of the next start, nor of an arrival after it, where the next walk has no time', () => {
const result = walkTransitLeg({
leg: leg(A_END, B_START),
dayId: 10,
pair: { ...pair(), next: { item: tourItem(2, B_START, B_END), tour: HARBOUR } },
stops: { first: [], next: [] },
places,
t,
});
expect(result.walk.nextStarts).toBeNull();
expect(lateForWalk(result.walk, '2025-06-01', { date: '2025-06-01', time: '23:50' })).toBeNull();
});
it('ends a loop at its first stop again', () => {
const result = walkTransitLeg({
leg: leg(A_START, B_START),
dayId: 10,
pair: pair({ place: '10:00' }, true),
stops: { first: [stop('a', 'Town hall'), stop('b', 'Old bridge')], next: [] },
places,
t,
});
expect(result.from).toEqual({ name: 'Town hall', ...A_START });
});
});
describe('arrivesAfterStart', () => {
it('compares the clock on the day and counts a later date as late', () => {
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:10' }, '2025-06-01', '13:00')).toBe(true);
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:00' }, '2025-06-01', '13:00')).toBe(false);
expect(arrivesAfterStart({ date: '2025-06-01', time: '12:50' }, '2025-06-01', '13:00')).toBe(false);
expect(arrivesAfterStart({ date: '2025-06-02', time: '00:10' }, '2025-06-01', '13:00')).toBe(true);
expect(arrivesAfterStart({ date: '2025-05-31', time: '23:50' }, '2025-06-01', '13:00')).toBe(false);
expect(arrivesAfterStart({ date: null, time: '13:10' }, '2025-06-01', '13:00')).toBe(true);
});
it('says nothing without a start, a date or an arrival time', () => {
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:10' }, '2025-06-01', null)).toBe(false);
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:10' }, null, '13:00')).toBe(false);
expect(arrivesAfterStart({ date: '2025-06-01', time: null }, '2025-06-01', '13:00')).toBe(false);
});
});
describe('lateForWalk', () => {
const walk: WalkTransitContext = {
dayId: 10,
fromWalk: 'Old town',
toWalk: 'Harbour',
ends: '12:15',
endsNextDay: false,
endsAtLeast: false,
nextStarts: '13:00',
};
it('names the next walk a connection arrives too late for', () => {
expect(lateForWalk(walk, '2025-06-01', { date: '2025-06-01', time: '13:10' })).toEqual({
walk: 'Harbour',
time: '13:00',
});
expect(lateForWalk(walk, '2025-06-01', { date: '2025-06-01', time: '12:50' })).toBeNull();
expect(lateForWalk({ ...walk, nextStarts: null }, '2025-06-01', { date: '2025-06-01', time: '13:10' })).toBeNull();
expect(lateForWalk(null, '2025-06-01', { date: '2025-06-01', time: '13:10' })).toBeNull();
});
});
describe('journeyLate', () => {
const journey = { type: 'transit', reservation_end_time: '2025-06-01T13:10' };
const walkAt = (placeId: number, time: string | null) =>
({ type: 'place', sortKey: 1, data: assignment(placeId, { place: time }) }) as MergedItem;
const note = { type: 'note', sortKey: 0, data: {} } as MergedItem;
const late = (following: MergedItem[], reservation = journey) =>
journeyLate({ reservation, following, date: '2025-06-01', tourOf });
it('names the walk a saved journey now arrives too late for, notes aside', () => {
expect(late([note, walkAt(2, '13:00')])).toEqual({ walk: 'Harbour', time: '13:00' });
expect(late([walkAt(2, '13:30')])).toBeNull();
});
it('says nothing for another booking, another kind of tour, a walk without a start or a stop between', () => {
expect(late([walkAt(2, '13:00')], { type: 'train', reservation_end_time: '2025-06-01T13:10' })).toBeNull();
expect(late([walkAt(3, '13:00')])).toBeNull();
expect(late([walkAt(2, null)])).toBeNull();
expect(late([{ type: 'transport', sortKey: 1, data: {} } as MergedItem, walkAt(2, '13:00')])).toBeNull();
expect(late([])).toBeNull();
const plainPlace = {
type: 'place',
sortKey: 1,
data: { ...assignment(2, { place: '13:00' }), tour_place_id: null },
};
expect(late([plainPlace as MergedItem])).toBeNull();
});
});
@@ -0,0 +1,222 @@
import { clockOf, minutesOf, type TourListItem } from '@trek/shared';
import type { Assignment, Place, Reservation, TranslationFn } from '../../../types';
import type { MergedItem } from '../../../utils/dayMerge';
import { splitReservationDateTime } from '../../../utils/formatters';
import type { DayItineraryItem } from '../../Map/dayTourProjection';
import type { TransitLeg } from '../../Planner/transitLeg';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { tourPlannedTimes } from '../tourPresentation';
import { walkStartOf } from './walkSchedule';
/**
* CW-08: public transit between two city walks on one day. The leg runs from
* where the first walk really ends to where the next one starts, and departs
* when the first one ends: its start that day and its planned total, a total
* entered by hand before the one worked out. Where that cannot be worked out
* no time is made up. The walks stay two walks on foot; nothing here is saved.
*/
const DAY_MINUTES = 24 * 60;
/** Closer than this, in degrees, a leg's end is the walk's end: the route copies the coordinates. */
const SAME_SPOT_DEG = 1e-9;
/** Which tour a place is, as the trip's tour list knows it. */
export type TourOf = (placeId: number) => TourListItem | undefined;
/** What the transit search says of the two walks a leg runs between. */
export interface WalkTransitContext {
/** The day both walks are on: on another day the search no longer speaks of them. */
dayId: number;
fromWalk: string;
toWalk: string;
/** `HH:MM` the first walk ends that day; null where it cannot be worked out or ends on the next day. */
ends: string | null;
/** The first walk ends after midnight, on another day than the one searched on. */
endsNextDay: boolean;
/** The end leaves out a time not known yet, so the walk ends then at the earliest. */
endsAtLeast: boolean;
/** `HH:MM` the next walk starts that day; null without a start time. */
nextStarts: string | null;
}
type TourItem = Extract<DayItineraryItem, { kind: 'tour' }>;
/** A walk in the day's route: its item there and the tour it is. */
interface DayWalk {
item: TourItem;
tour: TourListItem;
}
/** The two walks a leg runs between. */
export interface WalkPair {
first: DayWalk;
next: DayWalk;
}
/** A walk the arrival comes too late for: its name and the `HH:MM` it starts. */
export interface WalkLate {
walk: string;
time: string;
}
function sameSpot(point: { lat: number; lng: number } | null, other: { lat: number; lng: number }): boolean {
return !!point && Math.abs(point.lat - other.lat) < SAME_SPOT_DEG && Math.abs(point.lng - other.lng) < SAME_SPOT_DEG;
}
function walkOf(item: DayItineraryItem | undefined, tourOf: TourOf): DayWalk | null {
if (item?.kind !== 'tour') return null;
const tour = tourOf(item.placeId);
return tour?.tour_type === 'walk' ? { item, tour } : null;
}
/** Whether the day's route goes to an item: in the route, and a walk or a stop with a location. */
function routeGoesTo(item: DayItineraryItem): boolean {
const { assignment } = item;
if (assignment.route_excluded || assignment.accommodation_id != null) return false;
return item.kind === 'tour' || (!!assignment.place?.lat && !!assignment.place?.lng);
}
/**
* The walk a leg leaves from its end and the walk it reaches at its start, or
* null where the leg is no such leg. The leg is told by the entry it leaves,
* as the connector carries it, since two walks of a day can end at one spot;
* it reaches the next point the route goes to, so that must be the next walk.
*/
export function walkPairAt(
items: readonly DayItineraryItem[],
leg: Pick<TransitLeg, 'from' | 'to'>,
tourOf: TourOf,
originId: number | undefined
): WalkPair | null {
const index = items.findIndex((item) => item.assignment.id === originId);
const first = walkOf(items[index], tourOf);
if (!first || !sameSpot(first.item.end, leg.from)) return null;
const next = walkOf(items.slice(index + 1).find(routeGoesTo), tourOf);
return next && sameSpot(next.item.start, leg.to) ? { first, next } : null;
}
/**
* When a walk ends after a start, in minutes after midnight, or null without
* a start or a total. Past midnight it ends on the next day. A list that says
* nothing of the stops leaves the stays out, so that end is a lower bound too.
*/
export function walkEndOf(
start: number | null,
tour: TourListItem
): { minutes: number; atLeast: boolean; nextDay: boolean } | null {
const times = tourPlannedTimes(tour);
if (start === null || times.plannedTotalMinutes === null) return null;
const minutes = start + times.plannedTotalMinutes;
return {
minutes,
atLeast: times.atLeast || (!times.manuallyOverridden && times.walk === null),
nextDay: minutes >= DAY_MINUTES,
};
}
/** A stop as named: its own name, else the trip place it stands for. */
function stopName(stop: TourPlannerWaypoint | undefined, places: readonly Pick<Place, 'id' | 'name'>[]): string {
if (!stop) return '';
return stop.name || places.find((place) => place.id === stop.linkedPlaceId)?.name || '';
}
export interface WalkTransitInput {
leg: TransitLeg;
dayId: number;
pair: WalkPair;
/** Each walk's stops as read; null where they could not be read. */
stops: { first: readonly TourPlannerWaypoint[] | null; next: readonly TourPlannerWaypoint[] | null };
places: readonly Pick<Place, 'id' | 'name'>[];
t: TranslationFn;
}
/**
* The search for a leg between two walks: from the first walk's last stop to
* the next one's first, each named, else said as the end or start of its walk,
* at the coordinates the day's route draws, so a journey saved there joins it.
* It departs when the first walk ends, and never at the time the leg's own
* point carries: that is when the walk starts.
*/
export function walkTransitLeg({ leg, dayId, pair, stops, places, t }: WalkTransitInput): TransitLeg & {
walk: WalkTransitContext;
} {
const { first, next } = pair;
const firstStops = stops.first ?? [];
// A loop ends where it starts, at its first stop again.
const endStop = first.item.loop ? firstStops[0] : firstStops[firstStops.length - 1];
const end = walkEndOf(walkStartOf(first.item.assignment), first.tour);
// Past midnight no time on the search's day is made up.
const ends = end && !end.nextDay ? clockOf(end.minutes) : null;
const nextStart = walkStartOf(next.item.assignment);
return {
from: { ...leg.from, name: stopName(endStop, places) || t('tours.walk.transit.endOf', { walk: first.tour.name }) },
to: {
...leg.to,
name: stopName(stops.next?.[0], places) || t('tours.walk.transit.startOf', { walk: next.tour.name }),
},
time: ends,
walk: {
dayId,
fromWalk: first.tour.name,
toWalk: next.tour.name,
ends,
endsNextDay: end?.nextDay ?? false,
endsAtLeast: end?.atLeast ?? false,
nextStarts: nextStart === null ? null : clockOf(nextStart),
},
};
}
/**
* Whether an arrival comes after a start on a day: a later date always, the
* same date by the clock. An arrival without a date is on that day. Nothing is
* said without a date, a start or an arrival time.
*/
export function arrivesAfterStart(
arrival: { date: string | null; time: string | null },
date: string | null,
start: string | null
): boolean {
const startMinutes = minutesOf(start);
const arrivalMinutes = minutesOf(arrival.time);
if (!date || startMinutes === null || arrivalMinutes === null) return false;
const arrivalDate = arrival.date ?? date;
return arrivalDate === date ? arrivalMinutes > startMinutes : arrivalDate > date;
}
/** The next walk a connection found in the search arrives too late for, or null. */
export function lateForWalk(
walk: WalkTransitContext | null,
date: string | null,
arrival: { date: string | null; time: string | null }
): WalkLate | null {
if (!walk?.nextStarts || !arrivesAfterStart(arrival, date, walk.nextStarts)) return null;
return { walk: walk.toWalk, time: walk.nextStarts };
}
export interface JourneyLateInput {
reservation: Pick<Reservation, 'type' | 'reservation_end_time'>;
/** The day's items after the journey, in their order. */
following: readonly MergedItem[];
date: string | null;
tourOf: TourOf;
}
/**
* A saved journey that now arrives after the walk it leads to starts, said
* while the plan is looked at and never written: a walk moved or a journey
* changed since shows here, and nothing is replaced. Only a public transit
* journey, followed by a walk with a start time, notes aside.
*/
export function journeyLate({ reservation, following, date, tourOf }: JourneyLateInput): WalkLate | null {
if (reservation.type !== 'transit') return null;
const next = following.find((item) => item.type !== 'note');
if (next?.type !== 'place') return null;
const assignment: Assignment = next.data;
const tour = assignment.tour_place_id === assignment.place_id ? tourOf(assignment.place_id) : undefined;
const start = walkStartOf(assignment);
if (tour?.tour_type !== 'walk' || start === null) return null;
const time = clockOf(start);
const arrival = splitReservationDateTime(reservation.reservation_end_time);
return arrivesAfterStart(arrival, date, time) ? { walk: tour.name, time } : null;
}
+36
View File
@@ -0,0 +1,36 @@
import type { TourVisit } from '@trek/shared';
import { offlineDb, type CachedTourStationVisit } from './offlineDb';
function rowOf(tripId: number, visit: TourVisit): CachedTourStationVisit {
return { assignment_id: visit.assignment_id, station_key: visit.station_key, status: visit.status, trip_id: tripId };
}
/**
* CW-03: a trip's walk visits as the server listed them. The rows it no longer
* lists go, so a stop taken back on another device does not come back offline.
*/
export async function replaceTripVisits(tripId: number, visits: readonly TourVisit[]): Promise<void> {
await offlineDb.transaction('rw', offlineDb.tourStationVisits, async () => {
await offlineDb.tourStationVisits.where('trip_id').equals(tripId).delete();
await offlineDb.tourStationVisits.bulkPut(visits.map((visit) => rowOf(tripId, visit)));
});
}
/** One day's visits of a walk as the server answered a mark of it; the other days stay as they are. */
export async function replaceAssignmentVisits(
tripId: number,
assignmentId: number,
visits: readonly TourVisit[]
): Promise<void> {
const own = visits.filter((visit) => visit.assignment_id === assignmentId);
await offlineDb.transaction('rw', offlineDb.tourStationVisits, async () => {
await offlineDb.tourStationVisits.where('assignment_id').equals(assignmentId).delete();
await offlineDb.tourStationVisits.bulkPut(own.map((visit) => rowOf(tripId, visit)));
});
}
/** The trip's walk visits as last cached, without the trip they were filed under. */
export async function cachedTripVisits(tripId: number): Promise<TourVisit[]> {
const rows = await offlineDb.tourStationVisits.where('trip_id').equals(tripId).toArray();
return rows.map((row) => ({ assignment_id: row.assignment_id, station_key: row.station_key, status: row.status }));
}

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