Compare commits

..

154 Commits

Author SHA1 Message Date
Maurice e225548c79 docs(tiles): add the 4.3.0 release banner
The 4.3.0 notes drop the plates and the tile index for the Keep a Changelog layout. This banner is the one image left, at the top. Scaled from 5292 to 2400 px wide so the body does not pull 4.7 MB.
2026-09-19 11:43:56 +02:00
Maurice e5bda89faa docs(tiles): give the index heading its own weight 2026-09-17 21:39:20 +02:00
Maurice 815020dd58 docs(tiles): tighten the space between index rows 2026-09-17 21:38:51 +02:00
Maurice ebfa266ee0 docs(tiles): flag the three headliners instead of enlarging them 2026-09-17 21:37:40 +02:00
Maurice f450208db4 docs(tiles): give the release's three headliners their own weight 2026-09-17 21:35:23 +02:00
Maurice d1d7857363 docs(tiles): close the gap under the index band 2026-09-17 21:33:42 +02:00
Maurice e7a5acbd68 docs(tiles): a header band over the release index 2026-09-17 21:32:25 +02:00
Maurice e02cc7ecf7 docs(tiles): carry the gap inside each index plate 2026-09-17 21:29:28 +02:00
Maurice d3219af062 docs(tiles): the release index as twelve small plates 2026-09-17 18:06:07 +02:00
Maurice ae2d522890 docs(tiles): a transparent spacer, so the release index can hold its columns 2026-09-17 18:03:45 +02:00
Maurice b513dde39e docs(tiles): plates for the road trip and Dawarich sections 2026-09-17 17:50:35 +02:00
Maurice dee2603d42 docs(tiles): give both plates a lit glass rim 2026-09-17 17:36:23 +02:00
Maurice 633a2b38a4 docs(tiles): two plates for 4.3.0, on the product's own dark palette 2026-09-17 17:35:01 +02:00
Maurice 2a66665935 docs(tiles): draw the road trip day as a rail, not a list
The card beside the headline was three places stacked, which is what the
planner already had before this addon existed. It is the rail now: one
line down the drive, a filled node for a place you are going to and a
hollow one for a halt on the way, the time and the distance on the line
between them, and the one thing that has to be seen before it is read,
a stop past the daily limit, in the only colour on the plate that is not
its own pair.
2026-09-13 01:16:04 +02:00
Maurice d2bc4cc297 docs(tiles): fill the Dawarich list out to its neighbour's height
Two stays beside a tall pane left a gap under the card that read as a
rendering fault rather than as space. Four fill it, and the list stretches
instead of centring.

The fourth figure was the nine scopes an API key can be narrowed to, and
the section of the release that explained them is gone; it says what the
page still says, that the addon is off until an admin turns it on.
2026-09-13 01:08:29 +02:00
Maurice 4c65b17a72 docs(tiles): let the hero lede run, and give the bars title room
The lede stopped at a width picked by hand, which broke the line early
and left a wedge of empty pane beside it; it runs to the pane's own
padding now, the same gap it keeps on the left. The bars title sits on
the ground rather than in a pane, so it takes more air under it than two
panes take between them.
2026-09-13 01:07:15 +02:00
Maurice 2e1db584bd docs(tiles): the bars title comes off the pane
It sat in a glass panel of its own with nothing else in it, which read as
a card that had lost its contents. It is the plate's heading now, on the
ground at the plate's own margin, the way the eyebrows sit elsewhere.
The winning row drops "alternating"; the bar beside it already says what
the two sources add up to.
2026-09-13 01:05:42 +02:00
Maurice 003c5826ab docs(tiles): the bars plate carries its title alone
The paragraph under it explained the corpus, which the sentence beside
the image on the release page already does. The plate is the claim and
the four bars behind it.
2026-09-13 01:03:43 +02:00
Maurice dff79089ab docs(tiles): road trip plates, and edits to the Places API set
Three plates for the road trip addon in a third palette, violet and
teal, so the page's three subjects tell themselves apart before a word is
read: the drive as one chain with a day's own card beside it, what it
finds along the road against what a list of days could offer, and the
four ways to send the drive where you want it.

The Places API set loses two headings that repeated the sentence above
them, and the half of each comparison that is history is drained of
colour and dimmed, by brightness rather than opacity so the pane keeps
hiding the route behind it. "Index" is gone everywhere in favour of TREK
API, and the bars drop the index-alone row.
2026-09-13 01:03:03 +02:00
Maurice f9dabccf19 docs(tiles): warm palette for the Places API half
Both halves of the release page were built on the same blue and green,
which are Dawarich's own two brand colours and mean something on its
plates. On the Places API ones they meant nothing and made the page read
as one long subject.

The aurora and the accents are four tokens now, overridden per plate and
nothing else with them: the Places API set is amber and rose, Dawarich
keeps the cool pair it takes from its mark.
2026-09-13 00:52:41 +02:00
Maurice f84564e2e9 docs(tiles): use the real trek API lockup in the hero
The mark was assembled here out of the plain wordmark and a hand-built
API chip, which is not the lockup: the official one has no leading glyph
and its own badge geometry. It ships as one file now, black on
transparent as delivered, inverted on the plate because that is the
dark-background variant.
2026-09-13 00:49:57 +02:00
Maurice 105cce5deb docs(tiles): put the API mark level with the headline
The plate is 16:5 and its right half was empty ground while the lockup
stacked above a two-line title and pushed the lede down the card. The
mark sits on the far side of the headline now, at the size that space
deserves.

The fit check was reading .stage, which is `height: 100%` and therefore
always reported the plate's own height; it measures the first and last
block instead, which is how the hero turns out to have been carrying 94px
of empty ground under it.
2026-09-13 00:45:46 +02:00
Maurice be5004e282 docs(tiles): one spacing step for every plate, and fit the heights
The gaps were whatever each plate happened to pick: 16 here, 22 there,
and a `margin-top: auto` that pushed a tile row flush against the card
above it. They are one token now, used between the stacked blocks,
between the tiles, and between a card and the numbers under it.

Every plate is also as tall as its content instead of a guess, so nothing
is clipped and no plate publishes a band of empty ground. The Places API
hero drops the release pill and carries its API badge at the size the
lockup is now, and one subtext in the comparison is a line shorter so it
stops wrapping.
2026-09-13 00:43:36 +02:00
Maurice b37d66a4c1 docs(tiles): redraw the Places API plates to match, and glass the rims
The release page carried two visual languages: flat cards for the Places
API and glass for Dawarich, one above the other. All five plates are the
same build now, from the same stylesheet, with the wording, the figures,
the bars and the row order of the originals left exactly as they were.

Each plate also carries the rim it was missing, a lit 1px edge and a
sheen along the top, so the image reads as a pane rather than as a dark
rectangle with rounded corners.
2026-09-13 00:39:56 +02:00
Maurice c620b44a7b docs(tiles): give both marks the same badge in the hero lockup
TREK's wordmark sat bare beside a chipped Dawarich, which read as one
brand hosting another rather than two meeting. Both are in the same glass
pill now, at a size the lockup deserves, with the multiplication sign
white between them instead of grey.
2026-09-13 00:30:36 +02:00
Maurice c58818e8f8 docs(tiles): round the Dawarich plates off and lift the small type
The plates are rounded now and carry transparency outside the radius, so
they sit on the release page as cards rather than as two black rectangles
with square corners against it.

The secondary lines were the same grey as the labels around them and read
as switched off at the size GitHub renders these; they are close to white
now. The second plate loses its heading, which repeated the sentence
above it, and the route line is one line instead of two.
2026-09-13 00:28:50 +02:00
Maurice 5e1b588791 docs(tiles): redraw the Dawarich plates in glass, and cut to two
The flat cards read as a spec sheet. Both plates now sit on the same
aurora, mesh and recorded route the app's own glass panels imply, with
rounded frosted panes over it; the hero carries a real From Dawarich row
so the first thing anybody sees is the product rather than a claim.

Two plates, not three: the third said in pictures what the text beside it
already says in words.
2026-09-13 00:21:49 +02:00
Maurice b4ea23203d docs(tiles): release art for the Dawarich integration
Three plates in the same dark palette and type scale as the Places API
set, built from the tokens in client/src/index.css, the TREK wordmark and
Dawarich's own mark: the lockup and the four numbers that matter, the
line between what stays in Dawarich and what reaches TREK as a question,
and the four places it turns up once it is connected.
2026-09-13 00:16:48 +02:00
Maurice b9d0f6df4b docs(tiles): close the gap the corpus note left behind
Blanking the two lines left a band of empty plate between the title and the
first bar, which read as a missing element rather than as breathing room.
The band is cut out instead, so the plate is 56px shorter and the spacing
above the bars matches the spacing below them.
2026-09-08 22:12:12 +02:00
Maurice aa482be840 docs(tiles): drop the corpus note under the top-5 title
The two grey lines explained where the 128 places came from. In a release
note that reads as a caveat before the numbers rather than context after
them, and the corpus is already described in the text around the image.
2026-09-08 22:10:57 +02:00
Maurice afa0dd0e33 docs(tiles): the two figures that got better read as such
72.7% and 58 ms take the success token, and their boxes a trace of it.
Only those two: tinting the licence line underneath as well would turn a
statement of fact into a claim.
2026-09-07 22:30:14 +02:00
Maurice 4be4c4ea11 docs(tiles): drop the eyebrow over the bars
The caption under the heading already says where the numbers come from.
2026-09-07 22:26:04 +02:00
Maurice 5db38e82f2 docs(tiles): a glass edge on every plate
The same treatment the repo's own tiles carry: a hairline ring at 12%
white, a brighter line along the top where the light would catch, and a
diagonal sheen falling off from the top left. The radius matches the
mask the PNG is cut with, so the ring follows the corners instead of
stopping at them.
2026-09-07 22:24:28 +02:00
Maurice 8cbea7ad47 docs(tiles): drop the eyebrow over the comparison
The heading above it in the post says the same thing.
2026-09-07 22:20:51 +02:00
Maurice 49c598a384 docs(tiles): close the gap under the hero subtext
The pills were pushed to the bottom of the plate, which left a hand's
width of nothing between them and the sentence they belong to. The plate
is as tall as its content now.
2026-09-07 21:21:27 +02:00
Maurice 1dd1ad838d docs(tiles): the hero field carries itself
The dashed drive and its five lit stops were one idea too many over a
field that already reads. Just the field now, and the light behind the
mark.
2026-09-07 20:59:27 +02:00
Maurice 7f5352efe5 docs(tiles): drop the route line from the hero
The field carries the plate on its own; the dashed drive across it was
one idea too many.
2026-09-07 20:58:46 +02:00
Maurice 96f5d9b587 docs(tiles): give the hero a field of places to stand in
73.6 million is a number nobody can picture, so the plate shows a field
instead of only naming one: 520 seeded dots weighted to the right so the
headline keeps a quiet ground, five of them lit and strung together like
a drive across it. The mark lifts off that on its own light.
2026-09-07 20:48:21 +02:00
Maurice 049cc50fda docs(tiles): both plates end on the same line
min-height only set a floor, and the right card's two wrapping notes
pushed past it, so the pair ended seventeen pixels apart. Both fill the
grid row now.
2026-09-07 20:39:06 +02:00
Maurice cd6f3b9d2c docs(tiles): figures in boxes, the old column read as old, rounded plates
The past column now carries a faint diagonal hatch and one step less
contrast on every word in it, so the eye sorts the two sides before it
reads either. Every figure sits in its own box, the three closing
numbers are centred, and the caption above them lost the sentence that
pushed it onto a third line. All four plates get rounded corners with
real transparency, so they sit on either GitHub theme.
2026-09-07 20:36:39 +02:00
Maurice 784237a02b docs(tiles): the wordmark beside the title, and the figures as badges
The subtext ran on across three ragged lines and the four figures sat in
a bare grid under a rule. They are pills now, and the sentence breaks
where it means to. The mark moves out of the corner into a plate beside
the headline, where it is large enough to read as the subject.
2026-09-07 20:26:02 +02:00
Maurice f9333d51a6 docs(tiles): three bars, and the arrow on the panel row
The chart carried a fourth bar for the index on its own, which is a step
in the reasoning rather than an option anybody can pick. Nominatim, what
ships, and Google is the comparison a reader is actually making.

The arrow sat in a column that included the metrics under the panels, so
it centred below them. It sits on the panel row now.
2026-09-07 20:24:41 +02:00
Maurice 802ac51904 docs(tiles): release art for the TREK Places API
Four plates in the TREK dark palette, built from the real tokens in
client/src/index.css, the wordmark component and the Lucide set the app
already uses: the hero, the before-and-after against Nominatim and
Overpass, the settings card at poster size, and the measured top-5 bars.
2026-09-07 20:20:52 +02:00
Maurice f1120be3c1 test(roadtrip): pin the via contracts
The schemas had no spec of their own. The two things worth pinning are the
ones the feature rests on: the anchor is a position and never an id, so a
fraction or a negative is not a leg of anything; and the batch route's
track has three states, because absent and null mean different things to a
day that already follows a line.
2026-09-07 18:36:54 +02:00
Maurice d3aad4c41e test(roadtrip): cover the planner half the mode switch unlocks
The road trip handlers in useTripPlanner are unreachable until the addon is
on for the instance and the mode is on for the trip, so the existing suite
never entered them: 360 of the file's new lines and branches had never run.

Turns both switches on and drives what the rail, the corridor panel and the
map call. The cases that matter are the ones where a via has to be
re-anchored: a via records which stop it follows by position, so a reorder,
a move between days or an insert that forgets the correction silently
re-shapes the drive.
2026-09-07 18:36:54 +02:00
Maurice 91801b10c0 test(roadtrip): cover the five client pieces that had no test
Five files, about 570 lines, that shipped without one. The cases are chosen for
what a later change could break quietly rather than for the line count.

useRouteAlternatives carries the decision the feature rests on: when a leg
already has vias, the road actually being driven is NOT among what the router
offers for the two bare endpoints, because it was asked a different question. It
is fetched separately and put at the top — without that, opening the picker on a
leg you have reshaped shows three roads, none of which is the one you are on and
no way back to it. Also pinned: a via that barely moves the route produces one
entry rather than two, a second ask abandons the answer still in flight, and only
driving, walking and cycling ever reach the router.

RoadtripStayModal writes `places.duration_minutes`, which the schedule has read
for years and nothing could write. The case that matters is that clearing sends a
zero and never a null: the update statement folds a null into "leave it alone",
so a null could give a stop a stay and never take one away. The clock cases pin
the thing a slider is for over a text box, which is watching the departure move.

StopKindPicker is a portal, so it gets the cases a portal makes easy to get
wrong: nothing without an anchor, outside-click and Escape both closing, the
anchor itself not counting as outside, and picking the kind a stop already has
taking it back off rather than setting it twice.

RoadtripCategoryPicker stays open on a click, which is the whole reason it is
multi-select, and names the kinds rather than counting them.

RoadtripAlternativesBar reports each offer by its own index rather than by its
position — a position drifts the moment the driven road is prepended — and shows
loading, failed and "only one way" as three different answers instead of an
empty list.
2026-09-07 18:36:54 +02:00
Maurice b2d9b25bb6 fix(roadtrip): say which filter emptied the list, and stop typing a limit racing itself
Four small things, and one that was not this branch's fault but is now its
neighbour.

The driving limits saved on every keystroke. Typing "180" sent three settings
writes carrying 1, 18 and 180, unordered and concurrent over HTTP/2, so
whichever the server committed last won: the number that decides every
over-budget warning could quietly end up a tenth of what was typed, and only
show it after the next reload. Typed locally now, committed on blur or Enter,
and a value that did not actually change writes nothing.

The empty-result message always blamed the name box. Four controls narrow that
list and only one of them is the name, so picking a section with no hits
produced `Nothing on the way matches ""` — empty quotes naming a filter the
reader never set. The count line above it was gated the same way, so the section
case lost its "x of y" as well. Both follow whatever actually narrowed the list.

The check-out dropdown printed a bare ISO date, the only place in the planner
that did; it goes through the same helper as the rail beside it.

Switching an addon off left open MCP sessions holding its write tools. A `when:`
gate runs once per session, at attach time, so the tools stay registered and
callable while the REST half answers 404 for the same user in the same moment —
and the list that decides whether to tear those sessions down had drifted:
airtrail and collections gate tools and were missing from it, which this addon
would have inherited. The list now sits beside ADDON_IDS and a parity test reads
the gates out of the source and holds it there. Read out of the source rather
than out of a runtime registry on purpose: a runtime set only holds the gates
whose modules the caller happened to import, so it would pass in a suite that
imports none — which is the shape of test that hid this in the first place.

RoadtripLimitsCard gets the test it never had, including the one case that
matters: three keystrokes are not three writes.
2026-09-07 18:36:54 +02:00
Maurice 44d52ab215 fix(roadtrip): stop one gesture writing twice, and one search running too early
Five things, all of them a click or a drag doing something the traveller did not
ask for.

The GL via handle committed a move on every pointerup. Any button started the
drag and there was no movement threshold, so a plain click wrote the via to
wherever inside the 17px handle the pointer happened to be — at a low zoom that
is kilometres, and the write re-routes the whole trip. A right-click ran the
drag AND the delete, so one gesture sent two writes; on the platforms where
contextmenu comes first, the move landed on an id that no longer existed and
reported a failure for a deletion that had worked. Leaflet gets this right for
free from `dragend`, which is why the two renderers behaved differently for the
same gesture. Primary button only now, with a threshold, and pointercancel puts
the pin back instead of committing — the browser fires it when the captured
element is torn out of the DOM, which is exactly what a delete does.

With the alternatives picker open, one click on the route fired both handlers.
Layer registrations are independent in MapLibre, and every alternative starts
and ends at the same two stops — when the leg already carries vias the current
road is itself one of the offers, so the overlap is the whole leg. The click
dropped a via AND chose a different road: two writes and two trip reloads for a
gesture made once.

The corridor search could be started before the day had routed. The corridor is
then built from the straight line between the stops rather than the roads
driven, and the routing answer landing a second later changes the line, which
clears the search mid-flight and drops every result — no error, no partial
result, just the idle empty state again. Routing runs one day at a time about a
second apart, so the window is wide open on a long trip.

Leaving road trip mode left the alternatives drawn on the map. The switch is in
the sidebar and reachable while the bar is open, and the overlay depends only on
the picker, so pale blue routes with drive-time pills stayed on an ordinary
planner map with no road trip UI left to dismiss them from.

Clearing a leg's vias swallowed its failure and closed the picker anyway. Every
neighbouring write in that hook reports one. The leg still carried its via and
still routed the old way, so the traveller believed they had undone the detour.

Walking and cycling legs were counted as driving. A day may legitimately mix
modes — splitIntoRuns exists for that — and a two hour hike was reported as
"over the longest drive allowed", its minutes went into the day's driving time
and its kilometres onto the tank, where a ferry crossing could trip a range
warning outright. A leg with no mode still counts, because every leg was a drive
before the field existed.

The road-trip domain also says out loud that it is online-only, which the repo
asks for rather than leaving it to be discovered. A via anchors by POSITION, so a
queued write replayed after somebody else moved a stop would re-pin the drive
onto a leg nobody chose, silently and hours later; the offline story needs the
anchor to be an assignment id first. Until then the surfaces disable the gesture
instead of accepting one and dropping it.

Six theme-lint markers move onto the line they exempt. The linter suppresses per
line, so a marker in the comment block above suppressed nothing and the six
road-signage colours the authors documented as intentional were counted anyway.
2026-09-07 18:36:54 +02:00
Maurice 239856f35c fix(settings): make the self-hosted routing engine an instance setting
The field was rendered in every non-managed user's own Map settings and written
to their personal settings row. The CSP is built from something else: bootstrap
reads the ADMIN default and puts that origin into `connect-src`. So filling the
field in was the one path the product offered, and it could not work — the route
calculator happily built its URLs against the typed origin and the browser
refused every one of them with a bare TypeError, not a status, so no fallback and
no retry could see it. Day-plan routes, transport legs, the mobile day route,
Studio's road route and the whole road-trip rail stopped routing, app-wide, with
a generic "route could not be calculated". Nothing in the UI could repair it
except emptying the field again, and there was no admin field to set the value
that would have made the origin allowed.

The repo has decided this shape once already: `llm_base_url` is admin-only
because it names where the server sends its own requests and only whoever runs
the instance can judge what is reachable. This is the same class for a different
reason, so it joins the same guard, which is now named for what it actually
covers. Clearing stays open to everyone, so a row written before the rule cleans
itself up rather than blocking the save.

The field moves to the admin's default-user settings, next to the tile URL and
the Carto key, where a value takes effect.
2026-09-07 18:36:54 +02:00
Maurice 65be8db122 test(roadtrip): cover the surface the domain shipped without
The domain arrived with 557 lines, a service test, and nothing else. No
controller test, no e2e, and no MCP test — so the guard chain, the trip scoping
on every route, the cross-trip track check and the day_edit parity were all
unasserted, and the whole domain sat in the 80 catch-all where it could shed
twenty points against the headroom of the domains beside it.

The e2e is the one that earns its place. `@RequireAddon` is metadata and does
nothing on its own, and AddonGuard is not global, so the ordering the
controller's own comment calls load-bearing is exactly the kind of thing a later
change removes without noticing — it had already happened once. Deleting the
guard now turns three cases red, which I checked by deleting it.

The MCP tools had never executed at all: the shared harness seeds the addon
disabled, so `when: roadtripAddonOn` kept all five from attaching. They now run
against the same checks the REST routes make, including the demo account and a
member without day_edit, because the repo requires the two surfaces to move
together.

Two client modules that had no test get one: the overlay builder, where two
decisions live that are not cosmetic — which offered road is drawn as the one you
are on, and that a label hangs where its own route is unlike every other, rather
than at a midpoint that can fall on a stretch two routes share — and the service
marker, where the thing worth pinning is that an ordinary place gets nothing
back, and that the anchor arithmetic includes the border.

The domain carries its own coverage floor now instead of hiding in the catch-all.
Pinned under the measured figure on purpose; the exact ratchet gets regenerated
with the repo's own script after a full run.

Along the way, roadtrip_vias and roadtrip_day_tracks join the reset list every
other domain's tables are on. Without that a via written by one case was still
there for the next one, which is how the first version of the MCP suite passed
an assertion it should have failed.
2026-09-07 18:36:54 +02:00
Maurice 41e256bf0f feat(roadtrip): answer the corridor search from the place index
Search along the route went to Overpass and only to Overpass, on the one path
where that hurts most: one search fans out over sixteen boxes, and each of those
races four public mirrors. The index answers the map's explore pills in
milliseconds where those mirrors take seconds, and the corridor was the reason
the index exists.

Two independent things kept it out. The corridor sends its categories as a
comma-separated list, and the index branch looked the whole string up as one key,
so it missed every time. And the four kinds a drive needs, fuel, charging, rest
areas and campsites, had no Overture terms mapped at all. They do now:
gas_station and fueling_station, ev_charging_station, rest_stop, and campground
with rv_park.

The branch is list-aware the way searchOverpassPois already was, and it labels
each hit with the category that produced it rather than with the list that was
asked for, because the client colours and groups its markers by that field and
"fuel,charging" is not a category. It answers all-or-nothing: a category the
index has no terms for sends the whole query to Overpass, since answering the
half it knows would silently drop the rest while the panel still reported a
finished search. The caller's limit is passed through instead of a hardcoded 50,
so a mixed search gets the same per-category budget the Overpass path spends.

The other outbound path of this addon changes with it. Importing a shared Google
route geocoded its waypoints by calling searchNominatim directly, which walked
past geocodeQuery and its index lookup. Through geocodeQuery an index hit costs
no throttle slot at all, and what the index does not know still falls through on
the background lane.

A place answered from the index also carries its chain and that chain's Wikidata
item, so a branch gets its logo on the pin here exactly as it does on the
Overpass path. Sockets stay an OSM field: a charging station answered from the
index reports "not stated" rather than claiming it offers nothing.
2026-09-07 18:36:54 +02:00
Maurice 1e1dd9b814 fix(roadtrip): keep the shaping a traveller did, through every path that moves a stop
A via records which stop it follows by POSITION, so anything that renumbers a
day's stops moves the ground under it. The road-trip rail corrects for that;
almost nothing else did.

reanchor() never renumbered `sequence`. It is allocated per leg and starts at 0
in each, and re-anchoring MERGES two legs whenever the stop between them goes
away — so leg A's 0,1 ended up beside leg B's 0,1, and everything that draws the
route orders by sequence. The drive ran A0, B0, A1, B1: forward, back, and
forward again. Persisted, surviving a reload, and unrepairable except by deleting
the vias. The old anchor is read before the updates and used as the primary sort
key, so the vias of the earlier leg stay ahead of the ones that merged into it.

Reordering the only two stops of a day deleted every via on it. A reorder is
modelled as remove-then-insert, and the removal half refuses to treat a two-stop
day as still having a drive — right when a stop really goes away, catastrophic
when nothing was removed. Two stops is exactly the shape "follow this track"
produces, up to nine vias on that one leg, gone to a single drag with no prompt
and no undo.

Removing a stop, reordering within a day, and assigning a place mid-day never
re-anchored at all, though the helpers exist and three other paths use them. The
detour then sat on a leg the traveller never chose, or on a leg that no longer
existed and vanished — until a later stop brought it back bending a road nobody
asked for. Reordering hands a whole new ordering rather than one move, so the
anchors there follow the stop they were pinned behind instead of arithmetic on
the index, which is correct for any permutation.

Duplicating a trip dropped all of it: `stop_type` was missing from the place
insert, and neither via nor day-track table was copied. The duplicate looked
complete and quietly drove somewhere else.

A failed read of the via list emptied it. That read runs after every write and
feeds the routing key, so a 502, a dropped connection or a tab going offline
re-routed the whole trip along the roads the traveller had steered away from,
with nothing said and nothing retried — indistinguishable from having deleted
them. Only a 404 or 403 empties now; anything else keeps the last known list and
says it may be stale.
2026-09-07 18:36:54 +02:00
Maurice c1f7587704 fix(roadtrip): a freshly placed via lost its first drag
Placing one triggers a re-route, the re-route lands about a second later, and
the render it causes rebuilt every via handle — which is exactly when somebody
reaches for the dot they just made. On the GL renderer that rebuild replaces
the DOM element outright, and a pointerdown belongs to the element it happened
on, so the drag went to an element that no longer existed. The second attempt
worked because by then nothing was pending, and an older via never showed it
because nothing was re-routing.

Two causes, both identity: the vias arrive as a fresh object on every reload
even when the rows came back identical, and `onMoveVia` is a fresh closure on
every render of the planner. Both were in the effect's dependencies.

The list is now keyed by what a handle is built from — id and position — so it
only changes when a via does, and the callbacks are read through a ref. A via
that genuinely moved still gets a new list, which is what redraws it where it
now is; the test pins both halves, because holding the element still must not
mean holding it in the wrong place.

Leaflet's markers had the same churn without the same symptom: a fresh
`position` array called `setLatLng` on every marker on every render, and a
fresh `eventHandlers` object unbound and rebound every listener. Same fix.
2026-09-07 18:36:54 +02:00
Maurice 676c82a308 feat(roadtrip): remember which track a day follows
The vias already made the day follow the road; nothing said which road. After a
reload the badge could only say "shaped by hand", and re-fitting after the stops
changed meant finding the track again by eye.

One row per day, written in the same transaction as the chain it describes: a
day claiming to follow a road whose vias never landed would be worse than a day
claiming nothing. Three states rather than two, which is why the field is an
object and not an id — absent leaves whatever the day followed alone, null says
it follows nothing, an object records the road it now takes. A bare nullable id
could not tell "do not touch this" apart from "forget it", and a hand-dragged
via on the same day must not wipe the label.

The foreign key is the point: delete the imported track and the label cascades
with it, so no day is left claiming to follow a line nobody can see. The vias
stay, because they are the drive.

Both write paths check that the place is a track of this trip before it becomes
a label — permission alone would let another trip's place id, or an ordinary
place, become a name that can never be drawn.

Also clears three reliability findings the scanner raised on this branch: two
default sorts given an explicit comparator, and a trailing-slash regex the
scanner is right to call backtracking, walked instead.
2026-09-07 18:36:54 +02:00
Maurice 6fca238440 feat(places): import the stops of a shared Google Maps route
Pasting a /maps/dir/ link into the list-import box answered "could not
extract list ID", which is the whole of the complaint: people share a drive
far more often than they share a list, and it is the same gesture — somebody
pressed Share in Google Maps. Which screen they were on is the URL's business,
so the box takes both and the controller decides.

No API key and no call to Google for the link itself: the stops are in the URL.
Three shapes arrive — the path form with its data blob, the documented
?api=1&origin= form, and a short link that is one of those after a redirect —
and a stop the link spells out in coordinates needs no geocoder at all. One
that is only a name is geocoded, keeping the name from the link rather than
the one the geocoder answers with: somebody who typed a nickname into Google
should not find a street address on their trip.

A name nobody can place is left out and counted rather than failing the
import, because a route of six stops with five findable is five stops more
than the traveller had. Coordinates are read as 1d/2d, longitude before
latitude, which is the reverse of a place link and the one way to put Berlin
in Somalia. A blob that does not line up with the stops is ignored entirely:
half a link read confidently is worse than one read as names.
2026-09-07 18:36:54 +02:00
Maurice 761ec4e66f feat(roadtrip): let a day drive along an imported track
An imported GPX track was a line drawn beside the day: the map showed the
scenic route while the day kept driving the motorway, so the distance, the
drive time and every warning belonged to a road nobody was taking. Norway
publishes its scenic routes that way, and so does every touring club worth
importing from.

A track becomes a route the day takes by refinement, not by conversion. Route
through what we have, find the point of the track the road misses by the most,
make that point a via, go again. Each round roughly halves the worst miss,
because the new via lands in the middle of what the last one left unfollowed,
so a scenic route usually fits inside ten vias — and the loop stops when the
road is close enough rather than after a fixed count. Every point of the track
would route exactly and take a hundred requests; two would route in seconds
and follow nothing.

The day is routed whole per round, not leg by leg: it is a single request
already, and per-leg would multiply every round by the number of legs against
a host that answers one request a second. The track is oriented to the day
first, because half of Norway's routes are published southbound, and the legs
it covers are cleared before the new chain lands, so applying a track twice
does not stack.

Offered from the day's own badges, and only once something has been imported.
Tinted while the day carries vias: the rail draws none of them, so that badge
is the only place a drive shaped by hand differs from one the router picked.
2026-09-07 18:36:54 +02:00
Maurice 620f4c6ecd fix(roadtrip): let the section filter shorten instead of overflowing
A nowrap label reports its whole width as its min-content contribution, so the
place-name select sat at its default `min-width: auto` and refused to shrink —
pushing the ± kilometres control past the panel's right edge instead of
ellipsing the name.

The menu that select opens now grows to its options rather than inheriting the
narrowed trigger, bounded by the panel it sits in so it never hangs over the
map: a list of identical shortened prefixes is not a list you can pick from.
2026-09-07 18:36:54 +02:00
Maurice 36ac5de84c feat(roadtrip): narrow the corridor to one stretch of the drive
A seven-hundred-kilometre day turns up seventy hits spread over the whole of
it, and the traveller wanting a break near Berlin has to read all seventy and
work out from "245 km along the route" which ones are anywhere near. That is
the wrong way round: you decide roughly where to stop, and then look at what is
there.

Picking a point narrows the list to what lies within 25, 50 or 100 km of it.
This is a subtraction rather than a second search, because the stops and the
hits are already measured along the same thinned line.

The midpoints of the legs are offered alongside the stops, and they are the
point of the feature: a break on a five-hour drive belongs in the middle of the
drive, not at either end. Asking only about stops would answer "what is near
Berlin" when the question is "what is near the halfway mark".

The chosen point is stored as which anchor it is rather than as a distance.
alongKm is measured against a line thinned with a tolerance derived from the
corridor width, so a kilometre figure kept here would quietly drift when the
width goes from 5 to 10; resolved through the stops it lands on the same place
again. It also names its day, so switching days cannot apply yesterday's break
to today's drive.
2026-09-07 18:36:54 +02:00
Maurice 061259b3d9 feat(maps): pass through what OSM knows about a charging station
The corridor query has always ended in `out center tags`, and the projection
has always kept six keys out of the whole tag set. Everything an electric car
needs to know was in the answer and went in the bin: which plug families the
station has, how many of each, at what power, how many vehicles fit, and whether
it charges. Reading them costs no extra request, no key and no second query.

The filters treat "not stated" as its own answer, which is the only honest way
to build them: OSM states a socket type on roughly a third of charging stations
and a power on fewer still, so a power filter keeps every station that says
nothing rather than reading silence as too slow. The plug dropdown offers only
the families this search actually turned up, because CHAdeMO in a list for a
corridor with three Type 2 posts on it is a filter that can only return nothing.

Socket names stay as they are printed on the plug. "Type 2" is not a string
anyone translates, and the lowercase tag key is not what is written on the
charger.

The tool description follows, as parity requires, including the coverage caveat
— a model that reads a missing power as "no fast charging here" would be wrong
about most of the map.
2026-09-07 18:36:54 +02:00
Maurice fc74991a15 feat(roadtrip): turn a place into a stop on the way from its own number
An ordinary place could not become a fuel stop without going through the full
form, and the number in the rail is exactly what changes when it does: a service
stop has none. So the number is the control. Click the 3, pick the pump, and the
3 becomes an orange disc while everything below renumbers itself. The disc of a
service stop opens the same picker the other way, with a row to make it an
ordinary destination again.

Six coloured discs rather than six lines of text: at that size the colour and
the shape are the label, and anyone who has seen one petrol stop on the map
knows the orange pump before reading anything.

Centred on the column rather than on the number, which sits hard against the
left edge and would hang the panel half over the map. The click stops at the
disc, so picking a kind never also selects the place underneath.

Also in this change, from the same round: the corridor search offers hotels, a
hit somebody could sleep at opens the popup in an overnight mode with a check-out
day instead of a dwell, and booking one writes the place, its day and the row in
day_accommodations together. A hotel keeps its number, because sleeping there
ends the day rather than interrupting the drive, which is why the category list
and the stop-kind list are now two lists instead of one.
2026-09-07 18:36:54 +02:00
Maurice 49f97af3fb fix(roadtrip): the range figure keeps counting until something actually refuels
Reported off a real day: Berlin said 245 km since the last fill-up and Sanssouci
said 121, as if the tank had been filled in between. It had not. The budget was
being zeroed at every warning, so the first figure was right and every one after
it was the length of one leg rather than the distance on the tank.

A warning is not a fill-up. Only a fuel or charging stop resets it now, which is
what makes putting one in the answer rather than a formality: the same day reads
125, 370, 519 without one, and collapses to a single mark with one.

Nothing is flagged on a fuel stop either. Reaching a petrol station on an empty
tank is the plan working, and a warning there would sit on the one stop that
answers it.

Also: the stop number sits level with the middle of its row rather than at the
top, the way the corridor list already places its badges. A number pinned to the
first line drifts off as soon as a stop carries a stay and a walk under its name.
2026-09-07 18:36:54 +02:00
Maurice 4215db2908 refactor(roadtrip): the limits as badges, and a quieter empty state
The three figures under the button were one run-on string joined by dots, which
says nothing about which number is which. Each takes the badge shell the rail
already uses, with its own icon: a clock, a calendar, a pump. The icon tile in
front of the title goes, because it was eating exactly the width the three
badges need to sit on one line.

Distances there go through formatDistance, so an imperial traveller reads miles
on the button and types miles in the dialog while storage stays kilometres.

EmptyState gains a compact variant. A sidebar state sits under its own controls,
where a line at page size competes with them; at the caption tier it reads as
what it is, a note about why the list below is blank.
2026-09-07 18:36:54 +02:00
Maurice 95c173a8a8 fix(roadtrip): put a driving finding on the stop it arrives at, not the one it leaves
Reported from a real trip: Hamburg, A1, HEM, Berlin. HEM to Berlin is 244.7 km,
and the badge on Berlin said 121 — which is the length of the NEXT leg, Berlin
to Sanssouci. The finding was anchored to the stop the leg departs from while
its wording ("since the last fill-up") is a running total, so the number never
matched what anyone adds up off the drive bands above it.

Both findings are true on arrival, so that is where they belong. The same trip
now reads 125 at HEM (21.5 + 103.4) and 245 at Berlin, which is exactly what you
get counting the bands.

A service stop carries them too. A charging halt is a stop like any other as far
as the tank is concerned, and HEM was the very stop whose figure went missing.

The corridor's empty state gets the mascot the rest of TREK uses: searching,
idle, and a confused look when a filter left nothing standing, so the picture
says which of the three it is before the sentence is read.
2026-09-07 18:36:54 +02:00
Maurice 282e3c6b13 refactor(roadtrip): TREK's own tooltips everywhere, and a visible edge on the warning badge
Eight native title attributes across the rail and the corridor panel, which
render as the browser's own grey box after its own delay and ignore every
appearance setting. All of them are the shared Tooltip now, the same one the
rest of the app uses.

The warning badge had its border in the soft tint of the warning colour, which
at sixteen pixels disappears into the card and left it as the only badge in the
row without a shell. It takes the warning colour itself.

Two tests moved with it: a tooltip only enters the DOM on hover, so what they
pin now is the visible difference between a pinned and a computed arrival —
weight and ink — rather than an attribute that no longer exists.
2026-09-07 18:36:54 +02:00
Maurice ea9de65550 feat(places): make the kind of stop editable, and enforce it on the way in
Three holes, one seam. Leaving the corridor popup by "more details" dropped the
kind and the dwell it had just worked out, so a fuel stop silently became a
numbered destination that counted in every total, in the badge and in the PDF.
Once set, the kind could never be changed back through the web UI at all. And
the REST route did not validate the field: both request schemas are open
objects, so stop_type: 'banana' was written straight to the column, where
isServiceStopType read it as false and the stop counted as a destination again.

The form now carries the field, so the popup's answer survives the trip through
it, an ordinary place can become a service stop, and a wrong kind can be taken
off. Editing keeps stop_type and deliberately leaves duration_minutes out: how
long a stay takes belongs to the rail's own dialog, and sending it from here
would overwrite what was set there.

The schemas gain the one field rather than closing: roughly 190 call sites post
whatever their form holds, and a closed object would reject all of them at once.
The doc comment claiming the list was already enforced is corrected — it was
describing an intention.

The two driving findings move into the stop's badge row in warning colours,
beside the stay and the walk from the road. All three say what this stop costs,
and one row reads as one answer rather than as notes stacked underneath.
2026-09-07 18:36:54 +02:00
Maurice e79a2591a1 refactor(roadtrip): put the driving limits behind a button, and the walk beside the stay
Two things the column was getting wrong. The limits sat open as a card, three
rows of a narrow sidebar for something set once on the first day of planning
and left alone after; they are behind a proper full-width button now, with what
is currently set riding on the button so the form does not have to be open to
read it. And the walk from the road was its own line under the stay, where it
looked like an unrelated note; it wears the stay's two-part badge and sits
beside it, because both answer the same question about a stop — one in time,
one in a walk.
2026-09-07 18:36:54 +02:00
Maurice dfb038d90f feat(roadtrip): say when a drive is longer than you drive
Roel and gfRally asked for three things: how far the car goes on a tank, how
long you are willing to sit behind the wheel in one go, and how much driving
belongs in a day. They are one feature. All three are a threshold, a walk over
the legs, and a finding.

Two things they deliberately are not. They are durations and distances rather
than times of day, because with no stop pinned to a clock the cascade produces
no times at all, for any stop, and a limit on an hour would simply never fire.
And the day limit is called driving time, not travel time, because the figure
it is measured against is exactly that: the badge beside it, which counts no
time spent at stops and no leg to a hotel. The card says so out loud rather
than leaving it to be discovered.

The range budget carries across midnight, since a tank does not empty
overnight, and resets after a finding: without that, 1800 km on a 600 km range
flags every leg after the first overrun. Only fuel and charging reset it, never
the other four service kinds — a two-hour lunch fills nothing, and nobody here
knows whether the restaurant has a charger in its car park.

Kilometres in storage, the traveller's own unit on screen. Skipping that round
trip means an imperial user types 400 meaning miles, 400 km is stored, and the
warnings arrive a third too early from then on.

No stop is suggested and no vehicle is modelled. OSM does not know what car
this is and neither do we, so the range is a number you type. Furkot does the
same, for the same reason.
2026-09-07 18:36:54 +02:00
Maurice 56266138c6 feat(roadtrip): show the halts a routing plugin puts on the drive
A plugin may already route a day in road trip mode, and the charging stops it
answered with were thrown away: the map was handed an empty list and the rail
never looked. That made "build it as a plugin" — the answer given to charging
prices, to ABRP and to the AFIR data — untrue in the one mode where it matters.

Each halt is measured along the run once and filed on the leg it is actually
driven on, using the leg lengths the router reported rather than by projecting
the stops too: leg distances are exact by construction, while two stops close
together project onto each other's stretch and would put a charge the wrong
side of a town.

The rail row is deliberately the poorest of the four. No number, no editable
stay, no arrival time, a hollow disc: the halt belongs to the provider, not to
the traveller, and everything filled on that rail is something they put there.
Nor is it ever written back — a plugin halt stored as a via would go out as a
waypoint on the next run, and the plugin would plan around its own charging
stop.

No time on the row for a second reason: the SDK contract folds the stop into
the leg duration, so the arrivals already account for it. Adding dwellSeconds
to the schedule would push every arrival after a charge back by the charging
time.
2026-09-07 18:36:54 +02:00
Maurice 11f4c740e1 feat(roadtrip): draw the last bit to a place the road does not reach
A router snaps every waypoint to the nearest road before it starts, and OSRM
does it with no distance limit at all. A viewpoint above a valley, a farmhouse
down a track, a marina at the end of a pier: the drive to it really begins
somewhere else, and the line said nothing about that. It either stopped short
for no visible reason or appeared to cut across open ground.

OSRM reports the snap position and its distance in every single answer, and
this client has thrown both away since the first route was drawn. Reading them
costs no request and no key.

The gap is drawn as a dashed spur in the route's own blue, over the route
rather than under it, because it is the one piece of the line that is not
driving. Thirty metres is the floor, which is about a dual carriageway and its
verge, so a place pinned off an aerial photo does not earn a dash. From a
quarter of a kilometre the rail prints the distance too, because that is where
"the car park is over there" turns into luggage, a gate, or a track a hire car
should not be on.

Vias are deliberately left out: a via is a shape handle, not a destination, and
a dash hanging off one reads as a fault.
2026-09-07 18:36:54 +02:00
Maurice d6eac772aa fix(maps): ask a router that refuses exclude only once
Both public hosts TREK ships with reject the parameter with HTTP 400: their car
profile is built without excludable classes, and only a self-hosted OSRM built
through the MLD pipeline has them. Every long leg was therefore paying two extra
requests, against a host that allows one per second, to be refused twice.

The first real attempt is the probe. Only a 400 is remembered, because only a
400 is about the host: a 429 is a rate limit, an abort is the user moving on,
and a NoRoute means this leg has no way round the motorway while the next one
might. Kept per base URL, so pointing the instance at an own router that does
support it starts asking again without a reload.

The comment above the fallback said none of this and read as a working feature,
which is why "only one sensible way to drive this" keeps appearing on legs that
plainly have another road.
2026-09-07 18:36:54 +02:00
Maurice 67163e7224 feat(roadtrip): lay a whole chain of via points in one write
The single-via route is right for a detour dragged by hand and wrong for
anything that derives its anchors from a line. Pinning a day onto a recorded
track or a signed scenic road means a couple of dozen points, and one request
each re-routes the entire trip once per point, spaced by the routing host's own
rate limit — twenty anchors would spend half a minute drawing routes nobody
asked to see.

replace_legs names the legs to clear rather than taking a flag for the day, and
that is the whole difference: a traveller who bent two legs by hand and then
adopts a scenic road for a third has not asked to lose the two.

The tools follow in the same change, as parity requires. Undoing a detour picks
this up too, where the vias of one leg were deleted one request at a time.
2026-09-07 18:36:54 +02:00
Maurice ba7bd1dd91 refactor(roadtrip): settle the routed-leg shapes before three features fill them
Three separate features write into the same two places here: the leg merge and
the final memo. The access lines want the snapped waypoints, plugin routing
wants the via points it was given, and the driving limits want findings that
belong to a leg rather than to a stop. Built one after another, the same two
lines get rewritten three times.

So the shapes land once, empty. RoutedLeg gains snapped and vias, RoadtripDay
gains driveWarnings and dayWarning, RoadtripRoutes gains accessLines, and the
memo becomes a walk rather than a map, because a range budget carried past
midnight needs somewhere to live between two days.

Schedule stays exactly as it was. Its own test compares the empty result with
toEqual, which checks both directions, so a third field there would fail it —
and a finding about the driving does not belong to a stop anyway.
2026-09-07 18:36:54 +02:00
Maurice c82a8f899f refactor(roadtrip): one table for the six stop kinds, not nine lists
The kinds were written out in nine places: the enum in @trek/shared, the
service list and the colours in the model, the corridor's category keys, the
map's road-trip categories, the popup's own table, two lookups in the rail, one
in the map markers and one in the corridor panel. Each carried a comment asking
the next person to keep the others in step, and it came apart anyway.

A rest area was a parking sign in the popup, on the map pill and in the panel,
and a coffee cup on the rail disc and the map marker, for the same stop. The
parking sign wins: the colour has been documented since it was written as "the
blue every parking sign in Europe is printed in".

stopKinds.ts holds one row per kind and everything else reads from it. It sits
beside the model rather than inside it because an icon is a React component and
roadtripModel.ts is deliberately React-free so the arithmetic can be tested on
its own; the colours and the service list stay there and are read from here.
Two flags rather than one, because they are not the same question: a hotel is
something the corridor can find, but sleeping somewhere ends the day rather
than interrupting the drive.

Seven tests pin the table against the two lists that cannot import it, so the
next kind added in one place and forgotten in another fails a test instead of
quietly counting as a destination.
2026-09-07 18:36:54 +02:00
Maurice 23bda0e1e1 test(roadtrip): register the via tools in the MCP test registry
The registry the MCP tests build had every other domain and not this one, so
the four via tools were absent from every assertion that walks it. A test that
asked whether they were reachable, or gated, would have passed without them
ever being there.
2026-09-07 18:36:54 +02:00
Maurice d1a13058a6 fix(roadtrip): make the addon gate on the REST routes actually run
@RequireAddon has sat on the controller since the domain was written and did
nothing: it is metadata, AddonGuard was not in the chain, and it is not one of
the three global guards either. The addon ships disabled (seeds sets enabled 0),
so on any instance that never switched it on, all six via routes answered
anyway. Two doc comments in this repo already describe the behaviour this
commit creates rather than the one it replaces.

AddonGuard leads the chain, the way reservation-import and collections do it,
because a disabled addon owes an anonymous caller a 404 rather than a 401.
2026-09-07 18:36:54 +02:00
Maurice 25249749ad test(planner): give the page fixture the road trip state it reads
The page reaches into routeAlternatives.open, roadtripRoutes.lines and
roadtripVias.byDay before it ever asks whether road trip mode is on, so a
fixture without them took all 47 wiring cases down with "Cannot read properties
of undefined". The mode stays off, which is the state every assertion here is
written against.
2026-09-07 18:36:54 +02:00
Maurice 9a0a6f8c3b fix(settings): the routing endpoint belongs to the operator
routing_base_url arrived on this branch and was assigned to neither managed
set, which MANAGED-KEYS-001 exists to catch. It is locked, alongside the other
infrastructure endpoints: the host it names is folded into the CSP once at boot
(routingCspOrigins), so a customer who repoints it lands in a policy that does
not know their router and the browser blocks the requests with nothing to
catch. MANAGED-KEYS-005 pins the list verbatim and is updated with it.
2026-09-07 18:36:54 +02:00
Maurice 7829043a00 test(app-config): pin the overpass budget against its own constant
The client-side budget moved to twenty-five seconds when the query timeout it
has to outlast was written down as a constant, and this assertion stayed on the
old twelve. Pointing it at OVERPASS_TIMEOUT_DEFAULT_MS is what the test was
after in the first place: the budget must be longer than the timeout the query
carries, and a literal here is exactly how the two came apart.
2026-09-07 18:36:54 +02:00
Maurice 7b5c8c1fae test(places): teach the e2e tables the stop_type column
Three e2e suites build their own trimmed places table with a plain CREATE TABLE
rather than going through the schema and the migrations, so the column this
branch added never reached them. Every assignment, day and place case that
touched a place failed on "no such column: stop_type", thirteen in total, and
none of it showed because the checks do not run on a draft.
2026-09-07 18:36:54 +02:00
Maurice eebe9a52e1 fix(places): let a stop type be corrected over MCP, not only set
create_place learned stop_type when the column arrived; the three tools that
could change a place did not. Over MCP the kind was therefore writable exactly
once and never afterwards: a place saved as fuel fell out of every stop count,
wore the wrong disc for good, and could only be put right through the REST API.

update_place and bulk_update_places take it nullable, which is how the service
already reads "turn this back into an ordinary place" (an explicit null rather
than COALESCE). create_and_assign_place takes it the way create_place does.

The description also named four kinds where the enum holds six, so the model
had no way to reach restaurant or sights.
2026-09-07 18:36:54 +02:00
Maurice f1fa155899 fix(roadtrip): gate the via tools on the addon, the way the routes are
The controller carries @RequireAddon(ROADTRIP), the four MCP tools carried
nothing. On an instance that left the addon off, REST answered 404 while
list_route_vias, add_route_via, reanchor_route_vias and remove_route_via kept
reading and writing, which is exactly the parity the repo refuses to break.

Same shape as budget: an addonGate constant, AddonsService held publicly so
the module-level gate can reach it, AddonsModule in the imports.
2026-09-07 18:36:54 +02:00
Maurice 4f57c1ee13 fix(pdf): stop printing a rental's counter twice with an arrow between
The car branch prints the endpoint chain whenever it holds two entries, which
an ordinary rental always does: picked up and returned at the same desk. The
printout then reads "Munich Airport -> Munich Airport", where before there was
no car branch at all and the line simply stayed empty.

Consecutive repeats now collapse and a chain naming one place only is dropped.
A genuine round trip keeps both ends, because Dresden sits between them.
2026-09-07 18:36:54 +02:00
Maurice 3d252ac994 fix(places): keep the hour a new place has always defaulted to
The road trip rail reads `duration_minutes` as a stay, and a place created
without one claimed an hour nobody had entered. The branch answered that by
binding NULL instead, which is right for the rail and wrong for everything
else: the column declares DEFAULT 60, three tests assert it, and the comment
on one of them says so in as many words. That default reaches every place in
TREK, not only the ones on a drive.

So the hour stays, and the road trip solves its own problem where it arises.
A stop added from the corridor search already brings its own figure with it
(RoadtripStopPopup's STOP_KINDS: ten minutes for fuel, twenty for a rest area,
thirty for a charger), and saveStopDraft passes it through, so the kinds that
would be misread as an hour never take the default in the first place.
2026-09-07 18:36:54 +02:00
Maurice e11ae6f056 feat(roadtrip): shape the drive — vias, alternatives and stops along the way
The road-trip addon's second half: the parts that let a planned drive be
argued with rather than only read.

Reshaping. A via records which stop it follows by position, and that position
is resolved fresh every time the day is routed — so inserting, reordering or
removing a stop moved the ground under every via after it, and the drive
silently snapped back to the road it had been steered away from while the
handle still sat on the map where it was left. The correction is computed
against the day as it stands and written with the same change, in one
transaction: a half-applied re-anchoring mixes two numbering schemes in one
day. The one judgement that needs geometry — which half of a split leg a via
belongs to when a stop lands in the middle of it — is measured on the road
actually driven, so a fuel stop added to a bent leg keeps the bend on the side
it belongs to.

Alternatives. OSRM answers most long legs with exactly one route, which read as
"there is no other way" when a perfectly good slower road existed. When it
offers nothing, the same drive is asked for again without the motorway, and
failing that without the tolls — one question at a time, stopping as soon as
one lands. The offer says what it is rather than how much slower.

Stops along the way. Everything the corridor search can find is a pause, not a
destination: fuel, charging, rest areas, campsites, food and sights all sit
inside the leg they were found on, carry their own colour and icon, and are
left out of every stop count. That colour is defined once and read by the rail,
the corridor list, the map pin and the picker — and it replaced the chains'
logos, which turned a corridor full of petrol stations into a row of
advertisements and left the brands with no logo on file looking like something
else entirely.

How long a stop takes was readable in the rail and writable nowhere; new places
were given an invented hour that nobody had entered. It is now set from the
rail, cleared as a zero because the update statement folds a null into "leave
it alone", and new places start with nothing.

Both columns are rebuilt on one type scale, anchored to the day plan's own
sizes so switching tabs does not change the size of the text beside it. The
rail reads as trip totals, day cards and a chain of stops with the drives
between them; the corridor column as what to look for, how the search is
getting on, and what it found. Six category pills over three lines became one
dropdown, which is the space the results wanted.

Also: the offered routes are framed on the map when the picker opens (`fitKey`
carries no coordinates and each renderer reads it as "the selected day"), road
trip mode does not cluster at all so the whole drive stays readable zoomed out,
and the drive-time labels finally render in the app's own typeface instead of
Leaflet's Helvetica. All of it in both renderers.
2026-09-07 18:36:54 +02:00
Maurice 1db55f734c fix(maps): wait longer than the query we ask Overpass to run
The query carries `[timeout:20]`, so the mirror is allowed twenty seconds of
work on it — and the fetch around it was aborted after twelve. A mirror under
load that would have answered at fifteen never got the chance, and the caller
saw a failed area.

That is what makes a corridor search along a long drive report half its
stretches as unsearchable: the stretches were being searched, we just hung up.
Both numbers now come from one constant, the client budget is the query's own
plus room to hand the answer back, and a test holds the relationship rather
than the two literals.
2026-09-07 18:36:54 +02:00
Maurice b339ef3f04 fix(days): keep a place's stop type on both assignment loaders
Three read paths built the same nested place object by hand, and two of them
left `stop_type` out of it while their SQL was already selecting the column.
The effect was visible: a petrol station added along a route drew correctly
for a moment from the optimistic write, then the server's answer replaced it
with a place that had no kind, and the road-trip rail redrew it as an ordinary
numbered destination.

`getAssignmentWithPlace` now goes through `formatAssignmentWithPlace` like the
list path already did, which removes the third copy rather than adding the
missing field to it. The two queries in the days service gain the column, and
its hand-built shape gains the field it cannot yet be spared.
2026-09-07 18:36:54 +02:00
Maurice f1eadd670d test(roadtrip): read the last mock call without Array#at
The client's lib is ES2020, where Array#at does not exist. Local typecheck ran
before the file was added and CI caught it.
2026-09-07 18:36:54 +02:00
Maurice d6955f20a6 test(roadtrip): cover the routing, corridor and panel behaviour
The addon shipped with only its two pure modules under test, which left the parts
that actually talk to the network — and the panel a user reads the answer from —
resting on nothing.

Covers the routing hook (one request per travel-mode run, day default over trip
fallback, retry then give up, no re-fetch on a rename, abort on leave), the
corridor search (one query per box carrying every kind, the perpendicular filter,
dedupe, the retry pass before a stretch counts as unsearched), the corridor state
(roads driven over the line between stops, day follow-along) and both panels.

Two findings from writing them, fixed here:

- A dead straight leg is reduced to its two ends by the simplifier, and tiling
  walks point to point — so a 250 km motorway stretch produced a box at each end
  and nothing in between, while the search still reported itself complete. Steps
  longer than half a box are now split before tiling.
- projectOnSegment guarded a degenerate segment with `lenSq === 0`, which two
  ends that came through a chain of floating point arithmetic never hit.

Also splits the anchor resolution out of computeSchedule, which was three levels
of nesting for one question, and sorts the POI cache key with a comparator.
2026-09-07 18:36:54 +02:00
Maurice 6aa7884874 perf(maps): search a corridor with one query per box, not one per category
The road trip corridor asks for fuel, charging, rest areas and campsites over a
dozen boxes, and asking per category was four times the requests for the same
answer — slow, and four times the chance a shared Overpass mirror times out and
reports the stretch as unsearched.

`category` now takes a comma-separated list. Each OSM selector belongs to exactly
one category, so a hit is still labelled with the kind it answered, and the
result cap is shared out per category so one dense kind can't crowd the rest.
The MCP tool takes an array for the same reason.

Also puts the POI, plugin and route via-point pins on the map's render clock,
the same fix the planned-place pins already had: a library marker repositions on
every pointer sample while the canvas draws once a frame, so the suggestions
swam during a drag and snapped back on release.
2026-09-07 18:36:54 +02:00
Maurice 37179cb608 feat(maps): show a chain's own logo on its map pin
A corridor full of petrol stations was a row of identical coloured dots, and the one
thing that tells them apart is the brand. OSM carries brand:wikidata on most of them —
88% of the fuel stations in a Berlin box — Wikidata carries the logo as property P154,
and Commons serves it.

The bytes are proxied and cached rather than linked: one self-hosted instance asking
Wikimedia for a handful of logos is a very different egress profile from every
visitor's browser announcing which petrol stations they are looking at. The cache is in
memory because a GET must not write to the database, and there are only so many fuel
brands; a brand with no logo is remembered too, or every pan would ask again.

A logo-less or unreachable brand answers 204 and the pin keeps its category icon, so
nothing depends on Wikidata being reachable. Both renderers show it, since both read
the same category table.
2026-09-07 18:36:54 +02:00
Maurice 74258bd5d9 feat(roadtrip): give the corridor hits their own icons, and say when a stretch went unsearched
A petrol station and a campsite both came back as the same nameless grey dot, because
the road categories only existed on the server: the map falls back to grey for a
category it does not know. They now sit in the client's category table too — with the
pump, the bolt, the parking sign and the tent, in their own colours — which both map
renderers and the popup read from the same place.

They stay out of the explore pill's own list on purpose: that pill offers what a city
visit needs, and while road trip mode is on it is not on screen anyway.

The search also stayed quiet when only some boxes failed, which reads as "no fuel on
this stretch" when the truth is that nobody looked. A partial failure now says how many
stretches went unanswered.
2026-09-07 18:36:54 +02:00
Maurice 552bd61d11 fix(roadtrip): search along the roads driven, not the lines between stops
The corridor was built from the day's stops, so it followed straight lines across
country the car never drives. Between two cities that line runs through open fields
while the motorway swings wide of it, which is why every hit came back sitting on a
stop rather than on the way.

The days now carry the routed geometry, and the corridor is cut from that. Same trip,
same search: 192 hits spread over the whole 482 km — first fifty, middle, and the last
stretch — where before all of them reported kilometre zero.

Following real roads takes more boxes to cover the same drive, so they go out three at
a time instead of strictly one after another, and the budget rises from ten to sixteen.
The panel head loses the sentence explaining what the sections below already say, and
the day's distance and driving time, which the day list has been showing all along.
2026-09-07 18:36:54 +02:00
Maurice 647e972c8b feat(roadtrip): draw corridor hits on the map and build the day in one request
Three things the first cut got wrong.

Switching into road trip mode took its time: every leg was its own routing request,
paced a second apart so the shared hosts would answer at all. The router already
returns a leg per consecutive pair of the waypoints it is handed, so a day is now one
request — split only where the travel mode changes, because one request carries one
mode. Answers that come from the cache are no longer paced at all, which is what made
returning to the view feel broken.

A list of petrol stations is half an answer: which one is on your side of the road is
the other half. The corridor search therefore moved out of the panel into the planner,
and its hits are handed to the map like any other place suggestion — grey, because they
are not part of the trip until you add one. The explore pill stands down while the mode
is on: it draws into the same channel and answers the same question for a drive.

The panel itself was a stack of controls without an order. It now reads as header,
search, results: what is being searched (with the day's distance and driving time),
what is being looked for, how far off the route, then the hits grouped by kind with a
count — and a progress bar while the boxes are being worked through.
2026-09-07 18:36:54 +02:00
Maurice 07a4447bbd feat(roadtrip): find fuel, rest areas and sights along the drive
The road trip mode could show the route but not what sits beside it, which is
half of what planning a drive is about (#1797).

The right column becomes a corridor search: pick what you need, pick how far off
the route you will go for it, and the day's drive is searched box by box. The
boxes come from the route itself rather than the viewport, so the answer is
"on the way", not "on screen"; each hit reports how far off the route it lies
and how far into the drive it comes, and adding one opens the normal place form
so it lands on the day like any other stop.

Overpass is asked in plain bounding boxes rather than with a polyline filter,
because that is what the existing /api/maps/pois route accepts and what the
mirrors can answer; the geometry work is pure and tested. Long routes stop at ten
boxes and say so instead of quietly searching half the drive.

Fuel, charging, rest areas and campsites join the server's POI categories, so the
explore pill and the MCP search_pois tool gain them too. A car booking's stops now
also reach the PDF, which printed pick-up and return with everything in between
missing.
2026-09-07 18:36:54 +02:00
Maurice 3902e77991 feat(roadtrip): add the road trip addon with a drive-first plan view
The trip planner could show one day's route at a time and nothing across days,
so a road trip had to be pieced together in Google Maps (#1797, #435).

The addon adds a mode to the plan view rather than a tab of its own: the map
stays the workspace and gains the route over the whole trip, while the left rail
becomes one chain of stops with the driving distance and time between them and
arrival times that follow from them. A time pinned on a stop is kept and restarts
the chain, so a ferry or a museum ticket stays where the user put it.

Car bookings can finally carry the stops the schema has always allowed: the
endpoints table has held role/sequence for every transport type since #384, but
only flights and trains had an editor for them. The MCP tools already accepted
them, so this closes the gap the other way round.

Legs are fetched one at a time and spaced out — the public routing hosts answer a
whole trip's worth of requests with 429 — and the distance cap now looks at the
single leg instead of the sum, which is what dropped long drives entirely.
2026-09-07 18:36:54 +02:00
Maurice 7cdd61e7fb fix(maps): say which source answered, and stop the stubs driving the index
Two halves of one mistake, both found by the suite.

Letting each side of the merged search catch its own failure meant the index
could contribute nothing while the answer still called itself
"trek-places+openstreetmap". That label is not decoration: the search log writes
it into the corpus a candidate index gets scored against later, so a list that is
entirely OpenStreetMap must not be recorded as though the index had a hand in
it. The source now names what actually contributed.

And maps.service.test.ts drove the index for real. Its fetch stubs answer with
json() and no text(), which is not what the index client reads, so the branch was
entered and then failed on the stub — "uses Nominatim when user has no API key"
was really testing that a stub is incomplete. With the new circuit breaker those
failures also accumulated, and the breaker is process state, so it leaked into
whatever file ran next. trekPlacesSearch is stubbed there now, beside the nearby
stub that was already there for the same reason. The index paths keep their own
files.
2026-09-07 14:05:33 +02:00
Maurice ed5769fc89 fix(maps): name the index as the source, and stop it being asked twice
Attribution first, because this branch argues the point itself: the card names
the sources with the comment that naming them is a licence obligation rather
than decoration, and the copy beside it promises that every field says where it
came from. The POI path then stamped Overture rows as OpenStreetMap. Overture is
not OpenStreetMap — it carries OSM among other sources under other licences — so
that is a wrong attribution rather than a rounded one. The wire contract keeps
`source` an open string and nothing on the client switches on it, so saying
`trek-places` costs nothing. The MCP tool description no longer calls itself an
OpenStreetMap tool either, and says which source answered.

Adding one place cost two round trips to the index. The details lookup fetches
the place and its answer carries the description; toPlaceRecord dropped that
field, so the enrichment fetched the same place again a moment later to read it.
It is carried through now and read from what the caller already has, with the
second lookup left as the fallback for when there is nothing to read.

That description's source URL becomes an href on the client and was taken on
`typeof === 'string'` alone. It now goes through the same allow-list a place's
website goes through — the file already does this for menu_url, with the comment
explaining why — so anything but http(s) loses the link and keeps the text.

The instance token was hashed from raw APP_URL, which the shipped compose file
leaves commented out. Every default install therefore sent the same token, and a
token every caller shares is not a caller: one instance running a large import
would rate-limit all the others, who would fall back to Nominatim with nothing
to point at. getAppUrl() falls through to ALLOWED_ORIGINS and then to the port,
so unconfigured instances still differ.

Two client-side leaks. A pick made out of the offline cache was recorded into
the shadow corpus, where it moved the two figures the corpus exists to
measure — and offline is a setting, so the network is usually reachable and the
row landed. And the prefetch never re-checked the session after its request came
back: a logout in that window repoints the handle at the anonymous database, so
one person's trip area was written where the next person's offline search reads.

The cache path also ignored the abort signal, so an answer for a keystroke two
keystrokes old could overwrite a newer suggestion list. It rejects the way both
surfaces already know to swallow.

Left alone, and said in a comment rather than hidden: the index cannot localise.
The Overpass path picks name:<lang> and falls back to int_name; the service has
no language parameter at all, so a German reader exploring Tokyo gets the
Japanese primary names on this path.
2026-09-07 14:05:33 +02:00
Maurice b0aa7f8d95 fix(maps): stop asking an index that is not answering, and untie the place cache
Three things an install feels rather than reads.

A service that cannot be reached cost the full timeout on every call. Each call
site falls back to the old path, but pays 3.5 s first — so an instance behind a
firewall that drops rather than refuses, or one that simply has no route to the
service, pays it on every keystroke of autocomplete and on all three queries a
booking import runs per venue. Ten venues is over a hundred seconds of waiting
for an answer that was never coming. A process-wide breaker now fails instantly
after four consecutive failures and tries again a minute later, so an
unreachable index costs one timeout instead of one per request. A 404 does not
count against it, because "no such place" is an answer, and neither does an
oversized body, because the service replied.

The offline place cache hung off the map-tile switch. That setting is about
tiles — its own text says "off keeps the cache to trip data + documents only" —
and it gets turned off for space, tens of megabytes of it. A trip's places are
about one. Somebody who turned tiles off therefore got an empty offline search
with no explanation, and "prepare for offline" reported success having stored no
place at all. The two run independently now; the tile progress phase stays gated
on the tile setting, so a run without tiles still reports no tile phase.

Tests for the merged search path, which had none: the wiring around
mergeSearchResults was uncovered, so which list goes in first and what `source`
says were both free to change. Neither is cosmetic — index-first is the whole
point of the feature, and the search log writes `source` into the corpus a
candidate index gets scored against later. Two of the eight cases pin the fix
from the previous commit: an index failure keeps the OpenStreetMap answer, and
neither source is asked twice.

place-shadow lands on its own coverage entry rather than the 80 catch-all, where
a new domain could shed twenty points unnoticed. Pinned under its measured
figure on purpose; the exact ratchet gets regenerated with the repo's own script
after a full run.
2026-09-07 14:05:33 +02:00
Maurice 48f0f29273 feat(admin): let an operator switch the place index off
trek_places_enabled was read by six call paths — search, autocomplete, the POI
pills, booking-import geocoding, the offline area and details resolution — and
written by nothing. No admin route, no MCP tool, no switch anywhere in the UI,
and the key is not in the settings allow-lists either, so the only way to set it
was an INSERT against app_settings by hand on the server.

That matters more than the four switches beside it, which trade away quota. This
one decides whether every search and every keystroke in the place box leaves the
instance at all. The flag reads fail-open, so an install that upgrades without
visiting the admin panel starts sending them, which is the right default and
exactly why the operator needs somewhere to say no.

Adds the pair symmetrically to place-shadow from the same branch: getTrekPlaces
/updateTrekPlaces on AddonsService, GET/PUT /api/admin/trek-places with an audit
row, and a real switch on the card. The read stays fail-open so it keeps
agreeing with MapsService.trekPlacesEnabled(), and a test pins that agreement
across every stored value — if those two ever diverge the panel shows "off"
while the queries keep leaving, which is worse than either default.

The card said this itself: `available` was a display-only prop whose comment
read "False until the client is wired to the service; the card then says so
instead of offering a switch that would do nothing." It is wired now, so the
prop is a switch. Its two badge strings had been shipped to all 23 locales and
rendered nowhere; they make way for the switch label and a line naming what
answers while the index is off.
2026-09-07 14:05:33 +02:00
Maurice 5aa36aaf3d fix(maps): stop the index costing a second search, a duplicate and a lost cache
Six things the merged search and the offline cache got wrong.

An index failure took the OpenStreetMap answer down with it. Only the OSM half
of the Promise.all had a catch, so a rejection on the index side discarded ten
results that had already arrived and sent the same query to Nominatim a second
time, behind its 1.1 s process-wide throttle. The index refusing a query is
ordinary traffic rather than an outage — a common single word without
coordinates is turned down upstream as too expensive — so this fired in normal
use. Each side now catches its own failure, and the keyless fallback reuses the
answer OpenStreetMap already gave instead of asking again for a list that
cannot have changed.

Dedup never fired for a non-Latin name. namesOverlap splits on [^a-z0-9], so
長谷寺, Кремль and مسجد الشيخ زايد all tokenise to nothing and the function
returned false — the same place came back once from each source, one row under
the other, and picking either gave it a different id. That is the corpus this
whole feature was measured on. Two names with no Latin words are now compared
strictly, folded through NFKC so the width variants the sources disagree on
still match; sharing a character is deliberately not enough, because deduping
東京タワー against 東京タワー駅前郵便局 would swallow a real result.

expand=1 answered nothing for an index place while expand=0 answered in full.
The expanded lookup degrades OSM ids to the plain one and had no branch for the
new family, so the richer request was the poorer one, over REST and MCP alike.

The bias circle could exceed Google's 50 km ceiling. A trip spread over more
than that produced a radius Google rejects outright, turning a search into an
error toast. Clamped on both sides — the client because it draws the box, the
server because the radius arrives over the wire.

The offline cache was rebuilt on every sync and never deleted. syncTrip wrote
its meta row with put(), which replaced the whole record and dropped the
fingerprint saying the cached area was current, so every login, reconnect and
manual sync re-downloaded megabytes that had not changed. And clearTripData
never touched areaPlaces, so a trip switched off for offline use kept answering
offline searches and its rows stayed for the life of the install.
clearCachedPlaces, which claimed to do that job, was called from nowhere; its
only test asserted with .every() on an empty table and passed on nothing.

Picking an offline suggestion ended in an error toast. The details lookup had
no cache path, so it failed and the callers fell back to searching for
"name, address" — which the cache matches on the folded name alone, so it found
nothing for a place that was sitting right there. A miss stays a miss.
2026-09-07 14:05:33 +02:00
Maurice 273bac8917 fix(maps): keep index ids away from Google, and let the area download be big
Four things the index changed and nothing followed:

`gers:` was missing from NON_GOOGLE_PLACE_ID, so isGooglePlaceId said true
for every id the index hands out. Five call sites gate on that function to
keep unresolvable ids away from Google, which answers them with a billable
400 INVALID_ARGUMENT: photo refs, the editorial summary, the place-photo
route, the expanded details lookup and the enrichment photo pass. Since the
index answers first for search and autocomplete, a GERS id is what an
ordinary new place now carries, so this was the normal case rather than an
edge one — three billed lookups per added place, none of which could ever
return anything.

The area download shared the search path's megabyte cap and its 3.5 s
timeout. Both are right for a keystroke and wrong for the offline prefetch,
which asks for 3000 rows at once: a city-sized answer sits on that megabyte,
readCapped threw, and the caller could only report "index unavailable" — the
trip then went offline with an empty place cache and nothing said so. The cap
now follows the row count the caller asked for, with a floor, and the bulk
call gets a budget that fits a bulk download. It is still capped, so a broken
upstream cannot hand us an endless body.

searchPlaces returned before the Google branch as soon as the index or
OpenStreetMap had anything, and both set google_place_id to null. That is
right for a search and wrong for the one caller that wants an identity rather
than an answer: list-import enrichment (#886) exists to attach a
google_place_id to a bare imported pin, and pickEnrichmentMatch discards every
candidate without one. So the import silently stayed unenriched on an instance
that pays for a key. That caller now asks for the Google identity explicitly.
Nothing changes for an instance without a key — enrichment could never resolve
anything there, before or after.

Tests cover the three functions this branch had left untested, which is what
put the maps ratchet under its functions threshold: the autocomplete index
path (a new file, MAPS-AUTO-001..007), the gers details branch when Overpass
is down (MAPS-GERS-008), and the client's abort timeout — including that the
signal exists at all, which nothing asserted before.
2026-09-07 14:05:33 +02:00
Maurice 2b7af6267b test(server): cover what this branch added to the pinned domains
The coverage ratchet measures per domain, and this branch put new code into
four domains pinned well above the floor without tests reaching it.

addons (99/99/99): getPlaceShadow and updatePlaceShadow had none at all, and
the parity the service comment claims with PlaceShadowService.enabled() is now
asserted rather than asserted in prose.

maps (93/86/97/96): geocodeQuery was invisible - its only consumer stubs it
away - so the new suite drives the gate, an accepted hit, a hit with an
unusable coordinate, an index failure falling through, and both ends of the
Nominatim tail. It sits on the booking-import path, where it decides whether
thirty sequential lookups leave the instance.

admin (86/70/89/88) and place-enrichment (96/95/94/97): the place-shadow
handlers and their audit entry, the getAppConfig read, and nearbyWouldMislead,
whose lines ran incidentally while nothing asserted the result - the guard
could have been inverted and the suite would have stayed green.
2026-09-07 14:05:33 +02:00
Maurice d613c91e16 fix(places): keep the chain out of the field that means "this place"
toPlaceRecord wrote the brand's Wikidata item into `wikidata`, and
getPlaceDetails spreads the record over the OSM details, so for a chain branch
picked from the index that value replaced the building's own tag - and nulled a
real one when the place had no brand. resolveIdentity reads that field as the
place's own identity, so the photo ladder returned the chain's Commons files as
pictures of this branch and the description came back as the company's article
without the aboutBrand flag.

That is precisely the failure WIKI_IDENTITY_TAGS and readBrandIdentity were
written to prevent, in their own words: "a chain's logo passed off as a photo of
the restaurant would be a plain lie". The item now travels under brand:wikidata,
the key OSM uses and readBrandIdentity already reads, and resolveIdentity picks
it up from the payload for a place OSM does not know - so a branch still gets
the chain's description, flagged as such, and never its logo.

The client test also covers trekPlacesArea, the streaming half of readCapped
including the size cap, the configured base URL and the nearby defaults, none
of which any test reached.
2026-09-07 14:05:33 +02:00
Maurice 0991fa538b fix(maps): stop pois() dropping opening hours and reporting a narrowing as none
The index-first branch hardcoded opening_hours: null while the payload it maps
carries them, and search_pois advertises "opening hours wherever OSM carries
them". It also capped the radius at 20km and then answered clamped: false, so a
wide viewport came back narrowed and said it was not - the same field the
Overpass path fills honestly.

The three red MCP cases were the same code telling the truth from the other
side: they stub Overpass one level below the facade, so pois() ran for real,
and because trekPlacesEnabled() fails open on a database with no row, a unit
test was sending live requests to places.liketrek.com from CI. That is what
answered with 50 places instead of the stubbed one. The index client is now
mocked in that suite, with the gate left open so the cases still run on the
shipping configuration, and the index-first path gets cases of its own here and
in a new maps.pois suite: field mapping, the empty and the failing index both
dropping through to Overpass, and an outage under both providers still reaching
the caller as an error rather than an empty list.

maps.service.test.ts drove pois() with no stub at all and leaked the same way.
2026-09-07 14:05:33 +02:00
Maurice 65eebec53f fix(mobile): hint the suggestion fallback the same way the search is hinted
When details fails to resolve a picked suggestion the sheet falls back to a
text search (#1192). That call was the one search left in the sheet without a
location hint, so the fallback could land on an identically named place in
another country - exactly what the hint exists to prevent, on the path taken
when the first hop already failed.
2026-09-07 14:05:33 +02:00
Maurice 15c1a5ae06 test(mobile): seed the store the location hint is actually read from
useLocationBias reads useTripStore; the sheet's harness only ever built a
planner prop. So the hook saw an empty store, sent no hint, and the one case
that asserts a box went red while its three siblings passed vacuously - they
expect undefined, which an empty store produces no matter what they seed.

capturedBias now seeds places, assignments and selectedDayId the way the
running app has them, which puts all four cases back to work. The 500km case
is retargeted from Tokyo/Sapporo to Tokyo/Nagoya: 264km across, biased under
the old diagonal rule and dropped under the 60km radius one, so it guards the
rule that actually shipped. A new 029b pins the day-first precedence - a trip
too wide to bias still hints at the day being planned.
2026-09-07 14:05:33 +02:00
Maurice 4c52d16b7e test(admin): follow the API keys card to the theme tokens
The three AdminPage cases located the card with closest('.bg-white'), which
returned null once the card became bg-surface-card, and within(null) reports
that a line later as "container is null". Anchor on .rounded-xl, the same way
the green AdminSettingsTab suite already does, and ask for the single Test
button rather than the first of several: weather left the card when it stopped
needing a key.

FE-ADMSET-031 asserted the Open-Meteo panel inside that card. The panel was
deliberately removed from the desktop tab - it needs no key and had nothing to
configure - so the case now pins what took its place, the TREK index heading
the card with the recommendation the Google key used to carry. The panel itself
still lives in the mobile section, where FE-MOB-ASET-019 asserts it.
2026-09-07 14:05:33 +02:00
Maurice c0eb1c6e38 fix(maps): send the location bias the search route already accepted
MapsSearchDto validates a locationBias and MapsService passes it through, but
no caller ever sent one. That gap is the whole difference between the numbers
in this PR and what the software does. Measured against a live instance over
the 127 places of a real trip, searching each by name:

  without a hint   50.0% -> 54.8% top-5
  with a hint      50.0% -> 73.0% top-5   (this PR reports 72.7%)

mess/eval_merge.mjs sends lat/lng on every index call and a viewbox to
Nominatim; the client sent neither. Without coordinates the index refuses
common queries as too expensive and contributes to 41% of searches instead of
100%, so the merge degenerates to Nominatim alone.

The hint has to be NARROW, which is the part that is easy to get wrong. Same
corpus, same instance, varying only how the box is derived:

  no hint                            54.8% top-5, 38.1% at rank 1
  box around the whole trip          57.1% top-5, 17.5% at rank 1
  box around the nearest few places  70.6% top-5, 34.9% at rank 1

A box spanning a round trip has its centre in open country between the cities.
It points at nothing and costs more than half the rank-1 hits. useLocationBias
therefore prefers the day being planned, falls back to the trip only while it
still fits inside one region, and drops the hint past a 60km radius rather
than sending a misleading one.

This also replaces the two hand-rolled copies of the bounding box in the
planner dialog and the phone sheet, which shared a defect: Number(null) is 0,
so a place without coordinates entered the box as the Gulf of Guinea and
stretched it across the planet.
2026-09-07 14:05:33 +02:00
Maurice fb363f22c8 fix(maps): alternate between the index and OpenStreetMap instead of ranking them
Measured on 128 places out of a real trip to Japan, all saved through
the old search. Index alone: 51.6 percent inside the top five. Index and
OpenStreetMap alternating: 72.7 percent.

Two cleverer orderings were tried first and both lost. A score threshold
cannot separate the cases at all: at 0.75 it catches 19 of 62 misses and
throws away 6 of 66 hits. Promoting results whose name matches what was
typed is worse than it sounds, because "Hase Station" shares a word with
"Hase-dera" while OpenStreetMap answers in the local language and
returns the right temple under a name that shares nothing: that rule put
seven weak index matches ahead of the correct answer, which landed at
position eight. Alternating puts it at position two.

So the merge does not rank. Each source keeps its own ordering and they
take turns, index first. An exact business match still opens the list;
a landmark Overture does not carry is second rather than eleventh.
2026-09-07 14:05:33 +02:00
Maurice 3caafe0548 fix(maps): show OpenStreetMap alongside the index, not instead of it
Measured against 128 places out of a real trip to Japan, all saved
through the old search: the index answered 51.6 percent inside its top
five. For most of the rest it returned the shops AROUND the landmark
rather than nothing, which is worse than nothing, because ten plausible
wrong answers look like an answer.

Overture Places is a dataset of businesses and is very good at those. It
largely does not carry temples, bridges, riverside walks, viewpoints or
observation decks, which is a fair share of what somebody planning a
trip searches for.

Two ways to detect "the index does not have this one" were measured and
both failed. Its own score does not separate the cases: a 0.75 threshold
catches 19 of 62 misses and throws away 6 of 66 hits. Name overlap does
not either: 57 false positives, because "Hase Station" shares a word
with "Hase-dera".

So this does not classify. Explicit search asks both concurrently and
merges, name matches first and the index ahead of OpenStreetMap within
each group. Autocomplete is untouched, because Nominatim's policy names
it as unacceptable use. Nominatim also gets the location bias it never
had, without which "Hase-dera" returns the temple in Nara rather than
the one in Kamakura the user is standing beside.

Two booking-import cases still stubbed searchNominatim after geocoding
moved to geocodeQuery, and stubbed it with the old array shape, which
made "has coordinates" pass on an undefined lat.
2026-09-07 14:05:33 +02:00
Maurice bac85cb98a feat(places): opening hours from the index, with OSM still first
The index can now carry opening hours, from two sources: an
OpenStreetMap layer the service keeps as a separate database file, and
the schema.org data a place publishes on its own site, read in the same
page fetch the description comes from.

OSM stays the first choice where it has them. Measured across seven
countries it covers 27.5 percent of gastronomy, and an OSM entry
describes this exact object and gets corrected by people who walked past
it, where a chain's website often carries one set of hours for every
branch. The rest is what the operator publishes, which measured 8.3
percent on the same kind of place.

Both go through the same expansion into weekday lines, so a card reading
`opening_hours` gets one shape and not two.

Five maps.service cases went red when search started answering from the
index ahead of Google; they are about the shape of the Google request,
so they now switch the index off first.
2026-09-07 14:05:33 +02:00
Maurice 87a765b763 feat(offline): cache the trip's area so place search works with no network
An offline instance had the trip's own places and nothing else, so "find
a pharmacy near the hotel" had nothing to answer from: every lookup went
to a server.

The index can hand over a bounding box in one request, so it does, once,
next to the map tiles. A trip to Rostock is roughly a megabyte, refreshed
only when the trip's area actually moves. Search and autocomplete fall
back to it at the API layer, which is why all ten call sites got the
offline path without any of them learning about it, and the answer says
`source: 'offline-cache'` rather than pretending to be live.

Country packages stay what they were: a SQLite file for anyone who would
rather run their own copy of the service. A browser cannot query one
without shipping a WASM SQLite, and Germany alone would not fit in
IndexedDB, so that door stays where it belongs. /v1/packages lists them,
because "you can leave" only means something if the door is visible.
2026-09-07 14:05:33 +02:00
Maurice 715993feaf feat(places): descriptions from the place's own website
Overture has no descriptions, and the encyclopaedias only describe the
places that have an article, which is a fraction of a percent. What
Overture does have is the address of the page the description is on, for
43 percent of places, and those pages publish a summary in JSON-LD or
og:description precisely so machines can read it.

The API fetches each page once and keeps the summary; every instance
reads it from there. TREK never opens a business's website itself, which
would mean one request per instance per place against small servers.

In the ladder it sits below the encyclopaedias and above Google: an
article about this exact place beats the operator's own copy, but for a
restaurant or a shop the operator's summary is the only one that exists.
Credited by host and linked back, with no licence claimed.
2026-09-07 14:05:33 +02:00
Maurice b3d69aa760 feat(places): record which search result was picked, on this instance only
The corpus behind "is our own index good enough to replace Nominatim".
When somebody searches and then picks a result, the client posts the query,
the rank and the pick. Nothing else: no user, no trip, no session, and
coordinates only to three decimals.

Deliberately decoupled from the search endpoint. Correlating a response
with a later pick would mean handing out a ticket on POST /api/maps/search
and threading it through all fifteen call sites; the client already holds
everything the row needs at the moment of the pick.

Evaluation happens offline afterwards: export the corpus, run it against a
candidate index, count how often the pick came back in the top five. That
is why collection can start before such an index exists, which is the whole
point, because the corpus needs weeks of real use and the index needs a
server, and those two waits should not be serial.

Off unless an admin switches it on, and it reads === 'true' rather than
!== 'false'. The three older places switches fail open because a migration
backfilled a row for installs already using them; nothing uses this one, so
an instance that upgrades and never visits the admin panel logs nothing.

Reading is admin only, retention is 180 days, and the switch state is on the
unauthenticated app-config endpoint on purpose: whether an instance records
what its users search for is something a visitor may know before logging in.
2026-09-07 14:05:33 +02:00
Maurice 0a2fa32fc1 feat(admin): rebuild the API keys card around the TREK index
The card was a flat list: two key fields, four Google switches and a large
weather panel, all at the same level. That gave a switch you set once the
same weight as the key it depends on, and gave the loudest treatment on the
page to the one thing with nothing to configure.

Now three comparable provider blocks. The TREK index carries the accent and
the recommendation, because a key should be the exception rather than the
expected setup. Google carries no border badge, so it does not stand out,
and says plainly what it costs beyond money. Weather has left the card: it
needs no key and had nothing to set.

The "recommended" badge moved from Google to the index, which is the whole
point of the change.

Also here, and both are their own thing:

- The settings tab is two columns from xl up. Nearly every card is "label
  left, switch right", so the middle stayed empty while the page scrolled
  for ages. Explicit columns rather than a grid over the flat list, because
  a grid pairs cards row by row and leaves a hole under the shorter one,
  and these differ by hundreds of pixels.
- Pictures: the nearby fallback drops from 300m to 60m and is skipped
  entirely for shops and gastronomy. Measured across 600 places in six
  cities, 2 percent of ordinary businesses have a picture on Wikimedia
  against 70 percent of churches, so for a cafe the curated rungs never
  fire and the fallback always does. That is how the town hall ended up
  over the doner shop. It fails open on an unknown category, so landmarks
  keep the fallback that works for them.

vite.config reads the dev port and the API target from the environment,
defaulting to what they were. Several worktrees could not run side by side.
2026-09-07 14:05:33 +02:00
Maurice 7f2a63e7f7 feat(maps): search places through the TREK Places API
Adds a provider for the project's own place index and puts it in front of
the existing ones for search, autocomplete, details and the explore pills.
It needs no key, has no quota, and what it returns may be stored, which is
true of neither of the two providers it sits in front of.

Nothing about the Google path changes. Every call site falls through to
exactly what it did before when the index has no answer or the service is
unreachable, so a working search keeps working.

Two details worth knowing while reading:

- Details for a place from the index also resolve its OpenStreetMap record
  by name and coordinate, because the index has no opening hours yet and
  never has cuisine or a menu link. Without that the hours a user saw while
  adding a place vanished from its card afterwards.
- The explore pills keep reporting source 'openstreetmap'. It is a typed
  contract the client switches on, and the places are the same places.

TREK_PLACES_URL points at a different instance; unset means the public one.
2026-09-07 14:05:33 +02:00
Konstantinos Thermos 2a0ceb2687 feat(transit): let an admin route transit search through Google
Transitous has no GTFS coverage for much of Asia, so a search between two
Osaka subway stops finds nothing — the stops are not in the feed at all.
Add Google as an optional backend behind an admin setting, reusing the
install's existing Places key, and fall back to Transitous whenever no
key resolves.

Uses the Routes API rather than the legacy Directions endpoint the
request named: every other Google call here already speaks the
v1/X-Goog-Api-Key family, and Google closed the legacy endpoint to Cloud
projects created after March 2025.

Google returns walking as turn-by-turn navigation steps where MOTIS
returns one leg per walking segment, so consecutive walk steps are
coalesced into a single leg — otherwise a finely-sliced walk pushed a
real journey past the 20-leg cap and the whole itinerary was dropped.

Both responses now name the backend that answered. The fallback is
silent by design, which left an empty result ambiguous between "no data
here" and "the provider you picked never ran", and the admin panel warns
when Google is selected but no key resolves — or when only the admin's
own key is set, which serves them and nobody else.

Cost shaped the implementation, since Google bills where Transitous does
not: both field masks are cut to the cheapest SKU tier that still
answers the question, cache TTLs are longer than the Transitous path's,
and route alternatives ride along inside the already-billed request.

Addresses #1699
2026-09-07 09:01:56 +02:00
Maurice 8e2840b29d docs(helm): document the probes block, and survive probes being nulled
A probe map is exactly the value someone reaches for when their cluster is
unhappy, so the two ways of reaching for it should not break the render.
`probes: null` hit a nil pointer on `.Values.probes.liveness`; guarding with
`with (default dict ...)` also makes dropping a single probe work, which is what
you want on a pod whose liveness check is the thing killing it.

The rest is documentation the chart already gives every other value: a wiki
section next to Resource Limits, a Notes bullet, and a values comment naming the
one trap here, that a handler switch has to clear the shipped httpGet in the same
override or the API refuses the pod.

Verified with helm template: defaults render byte for byte what the chart
produced before, probes=null renders no probes, probes.liveness=null leaves one,
and helm lint passes.
2026-09-06 17:26:51 +02:00
Owen Holloway 0454a383b3 feat: make health probes configurable in Helm chart 2026-09-06 17:26:51 +02:00
dependabot[bot] 18684f20f3 build(deps): bump @simplewebauthn/server from 13.3.1 to 13.3.2
Bumps [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) from 13.3.1 to 13.3.2.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.2/packages/server)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-06 17:10:12 +02:00
github-actions[bot] 8913782694 chore: bump version to 4.2.0 [skip ci] 2026-09-06 17:10:12 +02:00
dependabot[bot] 52315234fd build(deps-dev): bump browserslist from 4.28.2 to 4.28.8
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.2 to 4.28.8.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.2...4.28.8)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-06 17:10:07 +02:00
Maurice 2572132117 fix(notices): let the 4.0.0 release modal cover the whole 4.x line
The version window already said 4.0.0 to 5.0.0, and the test above it is even
called "covers the whole 4.x line", but that was only half the story: without
`recurring: 'per-version'` a notice falls back to a one-time dismissal, so
everyone who closed the modal on 4.0.0 never saw it again. It did not come back
on 4.1, 4.2 or 4.2.1, which is the opposite of what the window says.

The mechanism was already there and in use one entry below, on thank-you-support.
The dismissal path records the app version it was closed at, so the notice
re-surfaces on the next upgrade and stays down within the same version. Rows
dismissed before that column existed read as 0.0.0 and simply come back once.
2026-09-06 16:50:47 +02:00
Maurice 755e9f1ca4 fix(trips): stop the two date fields overlapping in the trip dialog
A grid item defaults to min-width:auto, so the two date cells never went below
the width of their contents: a long locale date plus the keyboard button beside
it pushed each cell past its track, and on a phone the end date came to sit on
top of the start date with its own button clipped off the edge. Measured on a
412px viewport, the row overflowed its container by 30px.

Both cells get min-w-0 so the track wins, and the picker's own trigger gets it
too, which is what finally lets the ellipsis it already carries do its job
rather than the button simply growing. "12. Sept. 2026" now reads "12. Sept…"
in a narrow column instead of escaping it.

Keeping the locale format: the report suggests ISO 8601, but the date is
formatted from the user's language everywhere in the app, and the overlap is
the layout, not the wording.
2026-09-06 15:39:30 +02:00
Maurice b132adf3e3 fix(test): repair the escaped newlines and wire the new controller arg
The stderr and --version fixtures had literal line breaks in them, and the
admin controller helper still built the controller with eight arguments.
2026-09-06 15:03:13 +02:00
Maurice 94914295d1 fix(llm): let a model refuse temperature, and say which extractor is installed
Two reports, both about an import that fails without saying why.

#2262 — temperature: 0 sat in the shared request body, so it went out on every
attempt including both retries. Reasoning models (the gpt-5 family) reject any
explicit temperature, and the json_object retry fires on any 400, so the import
spent two doomed requests and could not succeed at all. The parameter is now
dropped once a server has explicitly said it will not take it.

Not as a third link in the chain, though. A gpt-5 rejects max_tokens AND
temperature, the API names one parameter per response, and it may name either
first — a chain of one-shot ifs converges on only one of the two orders. The
retries are a loop over what the server actually said instead: each remedy
applies at most once and they compose. Detection is a phrase, not the bare word,
because dropping temperature costs the deterministic sampling that small local
models depend on, and an unfamiliar wording still falls through to the
json_object retry exactly as before. NuExtract and Ollama are untouched: the
first attempt always carries temperature, and it is the only one they ever see.

#2261 — the bundled extractor is Debian trixie's 24.12.3, roughly eighteen
months behind upstream, and the reporter is right that this is invisible: a
stale extractor, a mistyped KITINERARY_EXTRACTOR_PATH and a genuinely
unsupported provider all look the same from the UI, which is nothing imported
and no reason given. The version now appears in the startup log and in a new
admin-only GET /api/admin/system-info, and LOG_LEVEL=debug passes the
extractor's raw stderr through — the JS ERROR and Invalid-result-type lines the
filter drops are the only evidence that a vendor script is missing or failing.
Capped at 200 lines, and opt-in because those lines can carry document
fragments.

Not bumping the package: the filter the reporter needs first ships in KDE Gear
26.08 and no Debian suite carries it, and sid's 26.04.3 wants KF6 6.22 and Qt6
6.10.2 against trixie's 6.6 and 6.8.2 — a partial upgrade of Qt6, KF6 and libc6
from unstable into the production image, on two architectures, that still would
not fix the reported carrier. It comes for free with the base-image move after
Debian 14.
2026-09-06 15:03:13 +02:00
Maurice 85f5247a00 fix(test): the card is labelled Total trip spend 2026-09-06 15:03:08 +02:00
Maurice 8a48131618 test: pin the empty-metadata payload and the payer-less share
Two suites encoded the behaviour this branch changes: MReservationSheet
asserted the literal metadata: null the forms no longer send, and the new
Your-share case reached for a label that is a card footer rather than a
heading.
2026-09-06 15:03:08 +02:00
Maurice 0a35ce83a5 fix: the follow-ups the v4.2.1 audit turned up
Five findings from auditing the release branch, none of them a blocker for it,
all of them things it would have shipped with.

The mirrored booking price only survived an edit when the form happened to fill
metadata of its own. Both reservation forms and the phone sheet sent
`metadata: null` whenever the form had no metadata key to send, and null clears
the column outright — so restaurant, event, tour, parking, other, a hotel with
no check-in times and a transport with no carrier details all lost the price
again on the next save. They send the object now: it still clears what the form
dropped, and the server carries the price across (#2233).

Your share counted expenses nobody has paid for, while the balances right below
it stopped counting them. The tile contradicted the panel it sits in (#2225).

App.tsx kept its own list of routes that must not mount authed-only widgets, and
it had already drifted from the one the response interceptor uses: this one
named /public/journey/, that one covers all of /public/. It reads
isAuthPublicPath now, so a route cannot be public to one and not the other.

LLM_TIMEOUT_MS and TREK_PLUGINS_IGNORE_TREK_RANGE shipped in .env.example but
not in the compose file most installs actually read.

And a typo in the Russian offline copy: Завершёна → Завершена.
2026-09-06 15:03:08 +02:00
Maurice eaace6ada4 fix(test): assert the untouched GET properly
toMatchObject({ method: undefined }) does not say what it looks like it says.
2026-09-06 15:02:58 +02:00
Maurice e6b4df5213 test(ssrf): pin what a redirect hop may carry across an origin
The credential strip and the 303/302 body downgrade are new behaviour in a
security helper, and src/utils sits on a 96% line ratchet that the untested
branches took under.
2026-09-06 15:02:58 +02:00
Maurice 2d315df8bf fix(security): keep the operator's ntfy token in and re-check every redirect hop
Two reported advisories, and a third path neither of them covers.

GHSA-7pqc-fj3c-9346 — the ntfy token fell back to the operator's whenever a
request did not carry one, so an authenticated user who named their own server
was handed the operator's decrypted token as `Authorization: Bearer`. The report
covers `test-ntfy`, but the live send path in channels/builtins.ts does the same
thing off a per-user `ntfy_server` setting: no test route needed, just wait for
the next notification. Gating the route on `role === 'admin'` would have closed
one of the two and broken the documented shared-server setup, where the operator
supplies server plus token and users only set a topic. The invariant is the
target, not the role: the operator's token travels to the operator's server and
nowhere else, and that rule fits both paths unchanged. Scheme is part of the
comparison — an http twin of an https server would put it on the wire in clear.

GHSA-8mw6-xphx-886m — both transports checked only the first URL and then let
the platform follow redirects on a dispatcher pinned to that first IP. Pinning
does not help: Node skips the pinned lookup for an IP-literal host, so a 307 to
127.0.0.1 or 169.254.169.254 connected straight through. They go through
safeFetchFollow now, which re-checks and re-pins per hop. safeFetch itself had
the same shape and carried it to sixteen more callers, several with a URL out of
a per-user setting, and one (pipeAsset) streams the response back — which would
have made a redirect into an internal service readable rather than blind. It
now routes through the same follower.

The third: following by hand opts out of the platform's own rules, and both
manual followers replayed the caller's init verbatim. undici drops
`Authorization` across origins itself, so converting ntfy to safeFetchFollow
would have re-opened the first advisory through a redirect. Credential headers
are now dropped on an origin change (same-host http to https stays, self-hosted
setups depend on it), and a 303 — or a 301/302 on a non-idempotent method —
drops the body, so a plugin's client_secret is not re-POSTed at a host the
first one merely pointed at. plugin-oauth pins maxRedirects to 0 like its OIDC
twin, and the Google Places calls get the timeout none of their nine call sites
passed.
2026-09-06 15:02:58 +02:00
Maurice 04b31ec2e5 fix(mobile): give the trip map a way back to every day
The phone map filters down to one day and had no way back out. The chip
rail can only swap one day for another, the second tap on the active chip
is the day sheet, and the seed guard deliberately does not re-seed — so a
day picked on the map stayed picked until you left the trip and came back
(#2257). Desktop has had the deselect since the day header got it.

Its own round control beside the rail, map view only: the plan timeline is
single-day, so there is nothing to widen there, and the day sheet keeps the
one route into it that also exists on the map. It toggles — the first press
parks the day it came from and frames the whole trip, the second puts that
day back with its route, or falls back to the focus day if a remote delete
retired it in between. Leaving the map restores it too, since an empty
timeline would be the worse answer.
2026-09-06 13:35:30 +02:00
Maurice 8eda1475c3 test(journey): pin the viewless-map window the photo layer fell into
The Leaflet mock had no getCenter, so the new guard bailed out of every
draw and FE-COMP-JOURNEYMAP-046/047 went red. Give the mock a loaded map
by default and add FE-COMP-JOURNEYMAP-050, which takes the view away for
exactly the window #2254 hit: render must survive it drawing nothing, and
the moveend listener must still put the thumbnail up once fitBounds lands.
2026-09-06 13:25:43 +02:00
xvilo ea0589b21e fix(journey): guard photo layer draw against map with no view yet
Public journey pages ≥1024px render the sticky sidebar map immediately with photos, before the deferred fitBounds/setView lands, causing latLngToContainerPoint to throw "Set map center and zoom first."
2026-09-06 13:25:43 +02:00
xvilo 523443f1d7 fix(app): stop mounting authed-only widgets on public visitor routes
SystemNoticeHost, BackgroundTasksWidget, and save-to-collection were gated only on login/register/password routes, so anonymous visitors to /shared/:token and /public/journey/:token still mounted them and hit system-notices/active with no session, logging a 401 on every visit.
2026-09-06 12:56:04 +02:00
Maurice 8c538db74d test(e2e): locate the panel tabs by aria-label, the tab bar has a Plan button too 2026-09-05 13:22:56 +02:00
Maurice 11a0780a5b fix(planner): give narrow screens one panel at a time, and unclip the public language picker
Three reports from the same week, all "the layout is in my way".

#2247 — the Plan tab shows both side panels from 768px up, unconditionally.
A Pixel 10 Pro Fold unfolded is ~860 CSS px, so 340 + 300 of panel left the
map a 200px strip; those same widths became fit padding, shrinking the
framing window to 140px and opening the camera two zoom levels out over half
of East Asia. The floating POI/compass cluster is centred on the viewport at
z-25, which at that width put it on top of both collapse tabs (z-1 inside
z-20 containers) and on the Add Place/Activity button — and its pills set
pointer-events: auto, so it ate the taps meant for them. The resize handles
are mousedown-only, so a touch user could not narrow anything either.

Between 768 and 1023px only one panel is open now, the day plan first, the
places list one tap away — the Google Maps shape the reporter asked for. The
collapse flags stay the wide-layout state, so a fold back restores what was
there, and a marker tap reopening "both" no longer re-crowds the screen. The
control cluster centres on the corridor the panels leave (the formula
PlaceInspector and DayDetailPanel already use), the tabs finally have
accessible names, the dead resize handles go, and a panel dragged to 520px on
a desktop is clamped so the map keeps 360px without touching the stored
width. computeMapViewport refuses to frame for less than a third of the box,
so absurd padding can never pick the zoom again.

#2248 — the public journey hero clips its own overflow to keep its
decorative circles from widening the document, which also amputated the
language menu at the hero's bottom edge: 8 of 23 locales, the rest
unreachable. Both share pages inlined the same menu, so it is one component
now, portalled to the body and anchored to its trigger the way CustomSelect
is, with a height cap and its own scroll. It closes on Escape and on an
outside click, which it never did, and `zh-TW` stops labelling itself 简体中文.

#2249 — the note row's pencil/trash pill is absolutely positioned over the
row's own reorder chevrons, and a coarse-pointer `opacity: 1 !important`
pinned it permanently visible on tablets. On a mouse it was no better: the
chevrons are revealed on hover, which is exactly when the opaque pill covers
them, so note reordering has never been clickable on any pointer. The pill is
gone; the row opens the note the way the phone shell already does, and delete
lives in that dialog behind the same confirmation. A link in the note body
keeps its own tap, on both shells.

Closes #2247
Closes #2248
Closes #2249
2026-09-05 13:22:56 +02:00
Maurice f75e9e9af8 fix(collections): read the created list off the response the server actually sends
POST /api/addons/collections answers with the collection itself — the
controller returns the service's `Collection` straight through, and the e2e
suite pins that shape. The client declared a `{ collection }` envelope and
read `.collection` off it, so `createCollection()` resolved `undefined` and
handed back `null` every single time.

The caller needs that id. In the list editor and its phone sheet the id is
what the cover upload and `onCreated` hang off, so creating a list with a
cover picked in the same dialog silently dropped the image, the new list was
never opened or selected afterwards, and the retry guard that exists to stop
a failed cover step from minting a duplicate was dead. The list itself still
appeared, because `loadAll()` runs first — which is why this went unnoticed.

Nothing caught it because both test layers stubbed the shape the client
believed in rather than the one the server sends: the api suite's MSW
handlers wrapped the collection in an envelope, and the store suite's mock
resolved one too. Both now answer the way the controller does, `update()`
included — its declared type was wrong for the same reason, though no caller
reads its result.

The fix is on the client. That wire shape has shipped for as long as
Collections has existed and the server e2e test pins it, so changing that
side would be the breaking one.
2026-09-05 13:19:01 +02:00
Maurice 3e532aab99 fix(planner): never link an offline temp place id to a hotel booking
The envelope fix routes the created place through the store, which offline
mints a NEGATIVE temp id: reservations have no offline write path, the
mutation queue rewrites temp ids in a URL but never inside another entity's
body, and day_accommodations.place_id carries a foreign key. So a
forced-offline save (the Settings toggle sets the flag while the network is
still live) reached the server with place_id: -1758..., failed the FK inside
the insert transaction and rolled the whole booking back into a 500 — where
dev merely saved it unlinked. Worse, the temp place is in `places` by then,
so every retry matched the same negative id and the booking stayed unsavable
for the rest of the session.

Offline the function now links nothing at all and skips the geocode too: the
booking cannot be written either way, so minting a place would only leave an
orphan behind on the next flush. The name-match arm rejects temp ids for the
same reason. `as never` is gone from the addPlace argument, and
placesApi.create finally declares its `{ place }` return type — an untyped
`r.data` is what let the envelope be misread in the first place.
2026-09-05 12:58:34 +02:00
Xheldon 6953ede0e3 fix(planner): link the place created for a hotel instead of minting a new one on every save
resolveImportedPlace created the hotel's place through placesApi.create and
read .id off the response, but that endpoint answers { place }, so the id was
undefined: the accommodation was saved without a place_id and the new place
was orphaned. Because the planner's places state never learned about it, the
name match failed on the next edit too, and every save of the same hotel
produced another copy (five in a row on one trip).

Create the place through the store's addPlace instead. It unwraps the
envelope, so the accommodation is linked, and it pushes the place into
`places`, so a later save of the same hotel reuses it. The mobile sheet goes
through the same handler and is fixed with it.

The hook tests mocked placesApi.create with a bare { id } and therefore
never saw the envelope; they now assert on addPlace, and a new case saves the
same hotel twice and expects one place.

Closes #2243
2026-09-05 12:58:34 +02:00
Maurice f81aaa9f4b test(login): stub the world animation so the takeoff assertions stop flaking
FE-PAGE-LOGIN-014 failed intermittently in CI while passing in isolation:
twice in a row on unrelated PRs, each time costing a full run, and once on
one of two runs of the identical commit.

The takeoff overlay renders LoginWorld, which starts three
requestAnimationFrame loops on mount. Under the load of the full suite that
was enough to push the overlay assertion past waitFor's default timeout.
Nothing in this file asserts on the animation, and LoginWorld has its own
tests, so the file stubs it.
2026-09-04 20:29:39 +02:00
Maurice 981cf0a143 test(maps): pin the RTL registration, and correct the precache figure
The guard in rtlText.ts had tests; the two lines that call it did not.
setRTLTextPlugin appears at exactly two places in the client and no test
touched either, so deleting a call, swapping an argument or dropping the
deferred flag left the whole suite green. The engines are mocked because
importing them for real builds a worker pool under jsdom, and the plugin
URL is mocked so the payload never loads.

The precache comment in vite.config.js was three releases stale, and it is
the only record in the repo of what a service-worker install costs. A real
build says 463 entries / 23,292 KiB, the plugin among them: it matches the
js glob, so it is fetched at install by every user rather than on first RTL
text as the branch description claims. That trade is the right one, since
the alternative is a CDN the production CSP refuses and an unshaped map for
anyone offline. Only the description of it was wrong.

Measured against a real client build, which no CI workflow performs: none
of them runs vite build or check:gl-split, so nothing here was covered
before.
2026-09-04 19:53:33 +02:00
Mohamed Elkmeshi dc7d1f5e10 fix(maps): register the RTL text plugin so Arabic labels join
Vector basemaps rendered Arabic, Hebrew, Persian and Urdu place names as
isolated letters in logical order — "القاهرة" drawn as "ة ر ه ا ق ل ا" —
because neither GL engine loads its optional RTL text plugin by default and
TREK never registered one. Raster basemaps hid the problem, since their
labels are drawn server-side, so it only became visible for everyone when
the default basemap became a vector style.

Both engine entry points now register the plugin lazily, so the payload is
only fetched once a map actually paints RTL text and Latin-only users
download nothing. The call is left with each engine because the two
signatures genuinely differ: MapLibre takes (url, lazy) and returns a
promise, Mapbox takes (url, callback, deferred).

The plugin is bundled rather than loaded from a CDN: TREK is offline-first
and the production CSP is script-src 'self', so a remote plugin URL would be
refused by the worker that imports it. Bundling also puts it in the Workbox
precache, so shaping keeps working offline. It needs a resolve alias because
the package publishes only its ESM/WASM entry point through "exports" while
setRTLTextPlugin requires the classic script in dist/ — the alias is shared
by the build and test configs so the two cannot drift.
2026-09-04 19:53:33 +02:00
Maurice 6f057ab9f4 fix(reservations): keep a mirrored booking price without keying it on the expense
The carry-over was conditional on a budget_items row still being linked to
the booking, which drops a real price in two shipped cases. The booking
importer writes metadata.price unconditionally but only creates the linked
cost when the Costs addon is on and the price is above zero, so an instance
with that addon off has bookings carrying a price and no budget row at all;
an MCP booking created with a price of 0 is in the same position. Both lost
the price on the first edit.

The condition is gone: a payload that does not name the key keeps what is
stored, and naming it, null included, is taken at face value so a price can
still be removed on purpose.

What stops a price from outliving its expense is now the other end.
deleteBudgetItem is the single chokepoint for all four delete paths, and it
clears the mirror there, inside the same transaction as the delete. That
also removes an ordering hazard the linked-row check had: the controller
runs update() before it syncs the budget, so on an explicit clear the row
still existed and the price would have come back a moment before it went.

The predicate moves into reservation-metadata.ts so it can be read and
tested on its own, and the update_transport tool description no longer
promises that metadata is replaced wholesale.
2026-09-04 19:08:15 +02:00
Xheldon ce58de29a5 fix(reservations): keep the linked expense price when a booking is edited
A booking's linked expense price lives in reservations.metadata.price
(written by BudgetService.syncReservationPrice). The booking form rebuilds
metadata from its fields on every save and only carries transit /
airtrail_ids over, so PUT /api/trips/:tripId/reservations/:id overwrote the
stored metadata and the price vanished from the card until the expense was
re-saved.

ReservationsService.update() now carries price / priceCurrency from the
stored metadata into an incoming metadata object that does not set them,
as long as a budget item is still linked to the booking. A payload that
sets price itself, a booking without a linked expense, and an explicit
metadata: null behave exactly as before.

Closes #2233
2026-09-04 19:08:15 +02:00
Maurice c5208967ac fix(app-config): refuse an LLM ceiling that would become a zero timeout
LLM_TIMEOUT_MS was validated as "a positive number", which lets two shapes
through that then degrade into an immediate abort rather than a long one.
A fractional value passes the check and floors to 0 in derive, which makes
the dispatcher fall back to undici's default and setTimeout(abort, 0) kill
every model call on the next tick. Anything past 2^31-1 does the same,
because Node clamps the delay to 1 ms. Both now refuse at boot, which is
what the wiki row already promised.

Also pins the seam the three deleted constants used to occupy: the clients'
abort deadline has to come from the configured ceiling. Nothing else in the
suite would have noticed a literal coming back.
2026-09-04 18:56:22 +02:00
Maurice 53c246e8e5 docs(wiki): the AI parsing addon now reads one environment variable
Three shipped sentences became false with LLM_TIMEOUT_MS: two claiming the
addon needs no environment variables (one of them in the very file the
variable was added to), and one naming the old five-minute ceiling that the
deleted constant used to set.
2026-09-04 18:56:22 +02:00
RemEx dba4434ef6 fix(llm-parse): one configurable ceiling for a model call, and undici honours it
The timeout for a model call was spelled three times and adjustable
nowhere: 120s in the Anthropic client, 300s in the OpenAI-compatible one,
300s in the Ollama one. None of them was the ceiling that actually
applied, either — every one of those calls goes through the pinned undici
dispatcher, and an undici Agent stops waiting for response headers after
five minutes by default, so a client constant set above that never got
the chance to fire.

- LLM_TIMEOUT_MS (default 900000) replaces the three constants and is
  passed to the dispatcher as headersTimeout/bodyTimeout, so the abort
  signal and undici are governed by the same setting. The default is
  deliberately generous: heavier parsing work should fit without a code
  change, and an admin who would rather fail fast can lower it.
- The ceiling stays on the model lane. safeFetchAdminConfigured is also
  the OIDC lane (discovery, token, userinfo, JWKS) and the plugin OAuth
  token exchange, and that one sends no AbortSignal of its own, so it
  keeps undici's five minutes rather than inheriting a model setting.
- The derived value is floored: the schema accepts any positive number
  and undici rejects a fractional headersTimeout.
2026-09-04 18:56:22 +02:00
Maurice 55f14c6a70 fix(offline): the PWA starts offline again and the download does what it says
Four separate defects sat behind "offline mode is broken".

The app shell. On the first online launch after a version bump, App.tsx
deleted EVERY Cache Storage bucket and unregistered EVERY service worker,
then reloaded. That is exactly wrong for a Workbox autoUpdate build, which
swaps its precache atomically on install and never leaves an empty one.
Between the purge and the completion of a fresh 22 MB precache the origin
had no shell at all; anyone who closed the app or lost signal inside that
window could no longer start the PWA offline. It also threw away the map
tiles and documents the user had downloaded on purpose. It now asks the
worker to fetch the new build and reloads once it has taken over. The
version marker also moves inside the guarded path, because it was written
after the purge and outside its try: a throwing setItem left the caches
gone, the marker unwritten and the reload unreached, repeating on every
launch.

The reported version is validated before it reaches this device's storage and
before it is compared against the stored marker, so a value that is not a
release tag can neither be persisted nor start an update on every launch.

The session. loadUser's offline branch kept the persisted session but
never called onAuthSuccess, the only caller of setAuthed(true), and the
mutation queue's flush, syncAll and prepareForOffline are all gated on it.
A session that launched without network could therefore never sync again
for the rest of its life, even after the signal returned: the offline
settings buttons spun and returned instantly having done nothing, and
queued edits never uploaded. It also left reads pointed at the anonymous
database instead of the user's own.

The date rule. A trip is only cached when it has not ended, and one that
ended over a week ago is actively evicted. The per-trip switch was an
opt-out list, so ticking a finished trip changed nothing at all. For
anyone whose trips are all in the past, "Download for offline use" stored
none of them, reported success, and the cache stayed at zero. Switching a
trip on now pins it, which overrides both the skip and the eviction, and
the switch shows the honest position rather than promising storage that
never happens.

What the screen says. A sync that never started (already running, offline,
signed out) was indistinguishable from one that stored everything: the
progress bar painted full and a green "Ready for offline use" appeared over
a run of nothing. Both syncs now report why they stopped and the screen
says so. A rejected Dexie read no longer renders as "No trips cached yet",
and the clear button stays reachable when the database holds data that no
bookkeeping row points at. A run that rejects outright says so too: the trip
list it starts from is a plain fetch, so a network blip took the whole thing
down and left the last progress frame on screen looking like a finished
download. The 401-with-HTML branch in the API client now
confirms a reachable proxy before tearing the worker down, since several of
TREK's own routes answer 401 with a text/html body.

Both settings shells move onto one shared hook rather than growing the same
logic twice, and the tile prefetch caps its depth when the browser refused
persistent storage. Opaque tiles are billed at about 7 MB each, and an
origin that is not exempt from eviction loses its whole bucket, precached
shell included, which Workbox then never refills because sw.js has not
changed.

Closes #2228
2026-09-04 14:32:44 +02:00
Maurice 6d54ac629e fix(oauth): accept private-use redirect schemes and loopback ports
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.

The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.

Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.

Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.

Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.

Closes #2227
2026-09-04 12:15:53 +02:00
Maurice bafcd456c5 fix(collab): notes show and keep their website on the phone
The note website round-trips correctly through the API and the desktop
grid renders it, but the mobile notes tab never carried the field: the
form had no input, the payload had no key, and neither the card nor the
read-only sheet showed a stored link. Since an editor only ever opens the
form and a viewer only ever opens the sheet, no role on a phone could see
or reach a website saved from a desktop.

Editing a note on mobile did not wipe an existing link. The key was
omitted rather than nulled, and the server's CASE WHEN guard kept the
stored value, so this was a visibility gap rather than a destructive one.

The form now always sends the field, null included, because the server
only clears on an explicit non-undefined value. Every render path goes
through safeExternalHref: the REST contract accepts any string, so an
unguarded href would execute a stored javascript: value in this origin.

The desktop expanded view had the same omission and is fixed alongside it,
so a member without collab_edit no longer loses the link there either.

Closes #2222
2026-09-04 12:15:38 +02:00
Maurice 9ed88adf34 fix(budget): an expense with no payer creates no debt
An expense saved with "No one paid yet" was still charging its split
members in calculateSettlement while crediting nobody, so the ledger
drifted off zero by the unpaid total. The greedy simplifier that turns
balances into payments has no memory of which expense produced which
debt, so it handed that phantom amount to whoever happened to be a
creditor from an unrelated expense. In the report, two people each owed
about 80 to someone who never paid that bill. Booking every payment it
offered then left one member permanently short, next to an "everyone
square" line.

Both cost panels already promise the opposite: they label such a row
"Unfinished, total only, not settled yet" and count it into the
Outstanding amount card instead. The server now agrees with them.

The guard deliberately does not also require a non-zero total, unlike the
panels' label predicate. PUT /budget/:id/payers with an empty array zeroes
total_price without re-applying it, which would otherwise leave a custom
split debiting its members against no credit at all.

The #1543 fixture and BUDGET-SVC-DB-004/005 were re-baselined: they pinned
the old arithmetic through a deliberately payer-less 9 000 RUB item. The
wiki said the old behaviour was intended, so it is updated too.

Closes #2225
2026-09-04 12:15:38 +02:00
jubnl bc10fba389 feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning

A plugin whose author has not updated its `trek` range for the running TREK can
now be installed, updated and activated when the operator sets
TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker,
post-extraction re-check, sideload, dev-link, activation) warns instead of
refusing; a missing range is tolerated the same way; "install latest" takes the
newest published version. The plugin-API version gate is not lifted and the
Discover compatibility verdict stays truthful.

The admin is warned at every step: each bypass is logged, install/upload/link/
update responses carry a `trekRangeBypassed` marker, and both admin panels show
a "Version checks off" pill, an "Install anyway" confirm dialog before a
registry install, the same notice after a path that could not ask first, and a
persistent chip on the row while the plugin runs outside its range.

Nine new i18n keys in every locale; wiki pages updated (Environment-Variables,
Admin-Plugins, Plugin-Development, Security-Hardening).

* refactor(plugins): one useRangeBypass hook behind both admin shells

SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass
logic had been added to the desktop and phone plugin panels as identical
copies. The marker/warning types, the row chip, the "Install anyway" offer,
the dialog copy and the confirm-or-notice state now live in
useRangeBypass (next to useInstanceSettings, the existing shared-logic
precedent); each panel keeps only its own dialog/sheet markup.

* docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule

The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3%
duplication, A ratings, hotspots reviewed) was only discoverable by waiting
20-25 minutes for the workflow. The root CLAUDE.md now states the conditions,
the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and
how to check a PR with the sonarqube MCP first; client and server CLAUDE.md
point at it. shared/CLAUDE.md says every locale needs a real translation —
parity cannot tell an English placeholder from a translation.

* docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy

Verified every concrete claim in the five CLAUDE.md files against the code
and fixed the stale ones: the websocket server is the Nest realtime gateway,
MCP tools live in <domain>.mcp.ts files, the client "native shell" section
described code that was never written, offline maps also prefetch vector
basemaps, plus several renamed files and wrong table names.

Then trimmed everything that drifts on its own: dates, migration history,
counts, and inventories of stores, slices, domains, sync files and CLI
commands. What stays is the commands, the invariants with the file that
enforces each one, what CI actually runs, the reference implementations to
copy, and the rules for new code. Each file now opens with that rule.
2026-09-04 00:43:06 +02:00
546 changed files with 38562 additions and 2312 deletions
+66 -51
View File
@@ -1,18 +1,17 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc.
## What TREK is
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo:
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime.
- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. Must be built before server/client typecheck or run.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
- **`plugin-sdk/`** (`trek-plugin-sdk`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`.
**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture.
Each package has its own `CLAUDE.md`; this file covers the monorepo picture.
## Commands
@@ -21,7 +20,7 @@ Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not
```bash
npm run dev # build shared, then watch shared + server + client concurrently
npm run build # build shared → server → client (order matters)
npm run lint # lint all three workspaces
npm run lint # lint all three workspaces (server and shared run eslint --fix and rewrite files; client is check-only)
npm run format # prettier --write all three
```
@@ -29,17 +28,14 @@ Tests:
```bash
npm run test # all workspaces
npm run test --workspace=server # vitest run (server)
npm run test:unit --workspace=server # tests/unit only
npm run test:integration --workspace=server
npm run test:ws --workspace=server # websocket tests
npm run test:e2e # server e2e (boots Nest against temp SQLite)
npm run test:cov # coverage (lcov), all four packages incl. shared + plugin-sdk
npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
cd client && npm run test # client vitest run
cd client && npm run e2e # Playwright
cd client && npm run lint:pages # enforce the Page pattern (see below)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared)
cd client && npm run e2e # Playwright (local only)
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
```
Run a single test file or test:
@@ -49,65 +45,84 @@ npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or c
npx vitest run -t "name of the test" # by test-name pattern
```
i18n parity (enforced in CI — every non-`en` locale must have identical file set + top-level keys):
i18n parity (CI gate — every non-`en` locale must have the identical file set and top-level keys):
```bash
npm run i18n:parity --workspace=shared # audit (exit 0)
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
```
Dev ports: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`).
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). There is no Node version pin — the CI workflows and the Dockerfile define what is tested. `server/.env.example` is the env-var reference.
## Server architecture
## Server invariants
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest/<domain>/`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`).
Nest owns everything: every domain is a DI module under `server/src/nest/<domain>/` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src/<domain>/`.
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter.
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub.
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service.
- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate).
## Client architecture
## Client invariants
Offline-first, with a layered data flow. A Page never owns state directly:
- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons.
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment.
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
## Shared contracts & i18n
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
## Project direction
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
These principles come out of a full-repo audit and shape all new code:
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`.
- **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
## Conventions (from CONTRIBUTING.md)
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages.
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.**
- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage.
- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes.
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
| Condition (new code) | Must be |
|---|---|
| Coverage | ≥ 80% |
| Duplicated lines | ≤ 3% |
| Security / reliability / maintainability rating | A |
| Security hotspots reviewed | 100% |
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. Locale files are excluded from duplication, so i18n additions are free.
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
## Reference docs
- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e).
- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide).
- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack.
+2 -1
View File
@@ -8,7 +8,7 @@ This is a minimal Helm chart for deploying the TREK app.
- Optional persistent storage for `/app/data` and `/app/uploads`
- Configurable environment variables and secrets
- Optional generic Ingress support
- Health checks on `/api/health`
- Configurable health checks (liveness/readiness probes) on `/api/health`
## Helm Repository
@@ -46,6 +46,7 @@ See `values.yaml` for more options.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
+6 -10
View File
@@ -80,18 +80,14 @@ spec:
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
{{- with (default dict .Values.probes).liveness }}
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with (default dict .Values.probes).readiness }}
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: data
{{- if .Values.persistence.enabled }}
+19
View File
@@ -134,6 +134,25 @@ persistence:
existingClaim: ""
annotations: {}
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
# httpGet handler. Switching handler means clearing the old one in the same override
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
probes:
liveness:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
readiness:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 100m
+45 -53
View File
@@ -1,77 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only.
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory.
## Commands (run from `client/`)
```bash
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build
npm run typecheck # tsc --noEmit
npm run lint # eslint .
npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs)
npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx})
npm run test:unit # tests/unit
npm run test:integration # tests/integration + co-located src tests
npm run test:coverage # v8 coverage
npm run e2e # Playwright
npm run e2e:report # open last Playwright report
npm run shots # Playwright screenshots project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate)
npm run typecheck # tsc --noEmit (CI)
npm run lint # eslint . (lint:check in CI)
npm run lint:pages # enforce the Page pattern (CI gate)
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
npm run e2e # Playwright (local only; e2e:report opens the last report)
npm run shots # Playwright screenshot project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only)
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (local only)
```
Single test:
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
```bash
npx vitest run src/store/slices/budgetSlice.test.ts
npx vitest run -t "optimistically adds the place"
npx vitest watch src/pages/DashboardPage.test.tsx
```
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both.
## Page pattern (enforced — `lint:pages` fails otherwise)
## Page pattern (enforced — `lint:pages` will fail otherwise)
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives.
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
## Data flow — offline-first
The layering is **store → repo → api | Dexie**, and writes go through a mutation queue:
The layering is **component → feature hook → store/slice → `repo/` → `api/` | Dexie**, and writes go through a mutation queue:
- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly.
- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection.
- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access.
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles.
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
## Direction (from the 2026 audit)
## Rules for new code
The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts:
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/<domain>.ts`, not the `client.ts` god module.
- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly.
- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use<Page>()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`).
- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug.
- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces.
- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter.
- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `<html>` styling.
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand.
## Other big-picture pieces
## Big-picture pieces
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/<locale>')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`.
- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens/<domain>/` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated.
- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill.
- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build.
- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / `<ServerImage>`; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`.
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here.
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below.
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
## Desktop ↔ mobile shells and the SonarQube duplication gate
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. Full gate rules in the root `CLAUDE.md`.
## Tests
vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`).
vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`).
+10 -1
View File
@@ -41,6 +41,12 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
// An iPad is inside the 768-1023px band, where only one side panel is open at a
// time so the map keeps a usable width (#2247). The places list is one tap away.
// Located by the tab's own aria-label, not by role+name: the tab bar carries a
// "Plan" button of its own.
await page.locator('button[aria-label="Places"]').click()
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
// The context must really be the one from the bug report: coarse pointer, desktop
@@ -74,6 +80,9 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
})
expect(['1', 'absent']).toContain(arrowOpacity)
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
// 4. The iPad must still get the desktop shell — isMobile stayed width-based. One
// panel at a time (#2247), so opening Places closed the day plan and left its
// tab as the way back.
await expect(page.locator('.leaflet-container')).toBeVisible()
await expect(page.locator('button[aria-label="Plan"]')).toBeVisible()
})
+1
View File
@@ -37,6 +37,7 @@
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.2.7",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
+25
View File
@@ -0,0 +1,25 @@
import { createRequire } from 'node:module';
import path from 'node:path';
/**
* setRTLTextPlugin() takes the URL of a classic script that the GL worker pulls in
* with importScripts. mapbox-gl-rtl-text ships exactly that as
* dist/mapbox-gl-rtl-text.js, but its "exports" field publishes only the ESM/WASM
* entry, so the resolver refuses the subpath. Resolving the entry it does publish
* and stepping across to dist keeps this working wherever npm hoists the package.
*
* Shared by vite.config.js and vitest.config.ts: the build needs it to emit the
* asset, and the test run needs it because a lazily imported engine module reaches
* the same specifier.
*
* The pattern is a regex, not a string key: the import carries a `?url` suffix,
* which a string alias will not match. It is left unanchored on purpose so the
* query survives the rewrite and Vite still emits an asset URL.
*/
export const rtlTextAlias = {
find: /^@mapbox\/mapbox-gl-rtl-text\/dist\/mapbox-gl-rtl-text\.js/,
replacement: path.join(
path.dirname(createRequire(import.meta.url).resolve('@mapbox/mapbox-gl-rtl-text')),
'../dist/mapbox-gl-rtl-text.js',
),
};
+77
View File
@@ -522,6 +522,83 @@ describe('Version cache-busting', () => {
renderApp('/')
await waitFor(() => expect(reload).toHaveBeenCalled())
})
it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => {
// The old handler wiped every Cache Storage bucket and every registration
// here, which left the device with no app shell until a fresh ~22 MB
// precache finished: anyone who closed the app or lost signal inside that
// window could no longer start the PWA offline at all, and it also threw
// away the map tiles and documents the user had downloaded on purpose.
localStorage.setItem('trek_app_version', '2.9.9')
const cachesDelete = vi.fn().mockResolvedValue(true)
const unregister = vi.fn().mockResolvedValue(true)
const update = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
getRegistration: vi.fn().mockResolvedValue({ update, unregister }),
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
addEventListener: vi.fn(),
},
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
// It asks the worker to fetch the new build; Workbox swaps the precache
// atomically once that install completes.
await waitFor(() => expect(update).toHaveBeenCalled())
expect(cachesDelete).not.toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
})
it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => {
// The value arrives over the wire and is written to this device's storage,
// so it is validated rather than trusted. An unusable one must also not be
// compared against the stored marker, or every launch would see a mismatch
// and try to update.
localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '<script>alert(1)</script>', managed: false })
)
)
seedAuth()
renderApp('/')
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => {
// The marker used to be written after the purge and outside its try, so a
// throwing step left it unwritten and the whole destructive path repeated on
// every launch, for good.
localStorage.setItem('trek_app_version', '2.9.9')
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
})
})
// Regression: a device that has never mirrored the preference must not treat
+50 -18
View File
@@ -19,7 +19,7 @@ import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
import { authApi, isAuthPublicPath } from './api/client'
import { tripRepo } from './repo/tripRepo'
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
@@ -283,7 +283,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -300,7 +300,7 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -314,26 +314,49 @@ export default function App() {
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) {
// A version is a short release tag and nothing else. It arrives over the
// wire and is written to this device's storage, so it is rebuilt from the
// characters a tag may contain and kept only when nothing had to be
// stripped. Anything else is ignored, which also keeps a malformed value
// from being compared against the stored marker and starting an update on
// every launch.
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
if (version && version === reportedVersion) {
const storedVersion = localStorage.getItem('trek_app_version')
if (storedVersion && storedVersion !== config.version) {
// Record the version BEFORE acting on it. The old code wrote the marker
// after the purge and outside its try, so a throwing setItem (private
// mode, blocked site data, quota) left the caches deleted, the marker
// unwritten and the reload unreached, and the purge then repeated on
// every single launch, permanently (#2228).
try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ }
if (storedVersion && storedVersion !== version) {
// A newer build is deployed. Ask the service worker to fetch it and
// hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim)
// installs the new precache and only then drops the outdated one, so
// there is never a moment without an app shell.
//
// This used to delete EVERY Cache Storage bucket and unregister EVERY
// worker instead. That left the device with no shell and no worker
// until a fresh ~22 MB precache finished (minutes on mobile data),
// and anyone who closed the app or lost signal in that window was
// left with a PWA that could no longer start offline at all. It also
// threw away the map tiles and file blobs the user had deliberately
// downloaded for offline use (#2228).
try {
if ('caches' in window) {
const names = await caches.keys()
await Promise.all(names.map(n => caches.delete(n)))
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
if (reg) {
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true })
await reg.update()
return
}
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map(r => r.unregister()))
}
} catch {}
localStorage.setItem('trek_app_version', config.version)
} catch { /* fall through to a plain reload */ }
window.location.reload()
return
}
localStorage.setItem('trek_app_version', config.version)
}
}).catch(() => {})
}, [])
@@ -380,13 +403,22 @@ export default function App() {
|| location.pathname.startsWith('/register')
|| location.pathname.startsWith('/forgot-password')
|| location.pathname.startsWith('/reset-password')
// No session on these, so authenticated-only widgets (system notices,
// background tasks, save-to-collection) have nothing to do and would only fire
// a doomed authenticated request on mount.
//
// Off isAuthPublicPath rather than a second hand-kept list: the two had already
// drifted, this one naming only /public/journey/ while the response
// interceptor's covers all of /public/. A route that is public to one and not
// to the other is exactly the seam that puts a 401 back.
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
return (
<TranslationProvider>
{!isAuthPage && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
{!isAuthPage && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
+21 -1
View File
@@ -230,7 +230,8 @@ describe('client > proxy auth challenges', () => {
return getRegistration
}
it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => {
it('FE-APIWIRE-011: an HTML 401 behind a confirmed proxy wall unregisters the service worker and reloads', async () => {
probeNow.mockResolvedValue('proxy-wall')
const unregister = vi.fn(async () => true)
installServiceWorker(unregister)
server.use(http.get('/api/auth/me', () =>
@@ -243,6 +244,25 @@ describe('client > proxy auth challenges', () => {
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1')
})
it('FE-APIWIRE-016: an HTML 401 from TREK itself keeps the service worker (#2228)', async () => {
// text/html is not proof of a proxy: several of TREK's own routes answer
// res.status(401).send('Authentication required'), which Express labels
// text/html. Tearing the worker down for one of those costs the user
// offline mode for a wall that is not there, so confirm reachability first.
probeNow.mockResolvedValue('online')
const unregister = vi.fn(async () => true)
installServiceWorker(unregister)
server.use(http.get('/api/auth/me', () =>
new HttpResponse('Authentication required', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(probeNow).toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => {
installServiceWorker(vi.fn(async () => true))
sessionStorage.setItem('proxy_reauth_attempted', '1')
+219 -12
View File
@@ -1,6 +1,8 @@
import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Place } from '../types'
import type { TransitProvider } from '@trek/shared'
import { randomId } from '../utils/randomId'
import {
weatherResultSchema, type WeatherResult,
@@ -59,6 +61,12 @@ import {
type PluginActionDescriptor,
type PluginActionResult,
type PluginInstallRequest,
RoadtripDayTrack,
RoadtripVia,
RoadtripViaBatchRequest,
RoadtripViaCreateRequest,
RoadtripViaReanchorRequest,
RoadtripViaUpdateRequest,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity'
@@ -214,16 +222,24 @@ apiClient.interceptors.response.use(
}
}
// Pangolin header-auth extended compatibility mode: returns 401 with an
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
// always application/json, so checking for text/html is unambiguous.
// HTML body (a JS redirect page) instead of a 302.
//
// A text/html 401 is NOT unambiguous on its own: several of TREK's own
// routes answer with res.status(401).send('Authentication required'),
// which Express labels text/html. Tearing the service worker down for one
// of those would cost the user offline mode for a proxy wall that isn't
// there, so confirm a reachable proxy first, exactly like the no-response
// branch above (#2228).
if (error.response?.status === 401) {
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
if (ct.includes('text/html')) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
if (await probeNow() === 'proxy-wall') {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
}
}
}
}
@@ -424,7 +440,10 @@ export const daysApi = {
export const placesApi = {
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
// Typed: an untyped `r.data` is what let `{ place }` be read as a bare place,
// so every hotel booking minted an unlinked duplicate (#2243).
create: (tripId: number | string, data: PlaceCreateRequest): Promise<{ place: Place }> =>
apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
@@ -453,8 +472,15 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
},
// A longer timeout than the shared 8 s, like the other routes here that wait
// on somebody else's service. A directions link whose stops are only named
// has to be geocoded one at a time behind a 1.1 s throttle, so a route with
// eight stops needs about ten seconds. Giving up at eight left the server
// finishing the import and writing the places while the browser reported a
// failure, and a retry then spent the whole geocoding budget again only to
// have the dedupe skip every stop.
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest, { timeout: 60000 }).then(r => r.data),
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
@@ -635,12 +661,18 @@ export const adminApi = {
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getTrekPlaces: () => apiClient.get('/admin/trek-places').then(r => r.data),
updateTrekPlaces: (enabled: boolean) => apiClient.put('/admin/trek-places', { enabled }).then(r => r.data),
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
updateTransitProvider: (provider: TransitProvider) => apiClient.put('/admin/transit-provider', { provider }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -1022,11 +1054,134 @@ export const memoriesApi = {
}).then(r => r.data),
}
/**
* Place search that still answers with no network.
*
* The cache is what `sync/placePrefetcher` stored for the trip's area, and this
* is the one place it is read from, so all ten call sites got the offline path
* without any of them learning about it. `source` says 'offline-cache' rather
* than pretending to be the index — several screens show it, and a stale answer
* that claims to be live is worse than one that says what it is.
*
* Only network-level failures fall through. A 4xx or 5xx means the server did
* answer and the caller has to see it.
*/
/**
* The same offline treatment as `withCachedPlaces`, for the single-place lookup.
*
* Separate because the shape is different: this one is keyed by id rather than
* by a query, and a miss has to stay a miss — a place the cache does not hold
* must fail the way it did before, not answer with somebody else's record.
*/
/**
* The rejection both search surfaces already know to swallow.
*
* They filter on the axios cancel code and on the DOMException name, so an
* abort from the cache path has to look like one or it surfaces as a toast.
*/
function abortedError(): Error & { code: string } {
const err = new Error('canceled') as Error & { code: string }
err.name = 'CanceledError'
err.code = 'ERR_CANCELED'
return err
}
async function withCachedPlace(
placeId: string,
online: () => Promise<{ place: Record<string, unknown> | null }>,
): Promise<{ place: Record<string, unknown> | null }> {
const fromCache = async (): Promise<{ place: Record<string, unknown> | null } | null> => {
const { getCachedPlace, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
const hit = await getCachedPlace(placeId)
return hit ? { place: cachedToPlaceRecord(hit) } : null
}
if (isEffectivelyOffline()) {
const cached = await fromCache()
if (cached) return cached
return online()
}
try {
return await online()
} catch (err) {
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
if (!neverArrived) throw err
const cached = await fromCache()
if (!cached) throw err
return cached
}
}
async function withCachedPlaces<T>(
query: string,
shape: (places: Record<string, unknown>[]) => T,
online: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const fromCache = async (): Promise<Record<string, unknown>[]> => {
const { searchCachedPlaces, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
const found = (await searchCachedPlaces(query)).map(cachedToPlaceRecord)
// The keystroke this answers may already be two keystrokes old. Both search
// surfaces order their suggestions by aborting the previous request and
// discarding the rejection — there is no request counter — so an offline
// answer that resolves regardless of the signal can overwrite a newer list.
// The two cache reads are also very unequal: a prefix hit comes off the
// index, the substring pass walks every cached place of every trip.
if (signal?.aborted) throw abortedError()
return found
}
if (isEffectivelyOffline()) return shape(await fromCache())
try {
return await online()
} catch (err) {
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
if (!neverArrived) throw err
const cached = await fromCache()
if (!cached.length) throw err
return shape(cached)
}
}
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
/**
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
* is the difference between finding the temple the user stands next to and
* one 400km away. The route has always accepted it; nothing passed it.
*
* It also decides whether the index answers at all: a common single word
* without coordinates is refused upstream as too expensive, and the search
* then falls back to Nominatim alone.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
withCachedPlaces(
input,
(places) => ({
suggestions: places.map((p) => ({
placeId: String(p.osm_id),
mainText: String(p.name),
secondaryText: String(p.address || ''),
})),
source: 'offline-cache',
}),
() => apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
signal,
),
// Answered from the cache when the network is not there, for the ids the
// offline suggestion list hands out. Without it, picking an offline
// suggestion failed here and the callers fell back to searching for
// "name, address" — which the cache matches on the folded NAME alone, so it
// found nothing and the user got an error toast for a place that was sitting
// in the cache all along.
details: (placeId: string, lang?: string, sessionToken?: string) =>
withCachedPlace(placeId, () =>
apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } })
.then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details'))),
// Pictures and a description for a place that is being looked at but not yet
// saved. Fans out to several providers server-side, so it takes a signal and
// the caller is expected to abort it when the selection changes, and a longer
@@ -1046,10 +1201,60 @@ export const mapsApi = {
// OSM-only POI explore: places of a category within the current map viewport bbox.
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
// timeout than the global default instead of aborting at 8s and showing nothing.
/**
* Every place in a box, for the offline cache. One call per trip area, not
* per keystroke, so the timeout is generous where the search ones are short.
*/
area: (
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number },
limit?: number,
signal?: AbortSignal,
) =>
apiClient
.get('/maps/area', { params: { ...bbox, limit }, signal, timeout: 30000 })
.then(
(r) =>
r.data as {
results: Record<string, unknown>[]
truncated: boolean
unavailable?: boolean
},
),
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
}
/**
* Road-trip via points (#1797): the places a day's drive is routed through without
* stopping. Separate from places on purpose — a via bends the route, a stop is somewhere
* you go.
*/
export const roadtripApi = {
/** Every via of the trip, so all days can be routed without a request per day. */
listVias: (tripId: number | string) =>
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }),
addVia: (tripId: number | string, dayId: number | string, body: RoadtripViaCreateRequest) =>
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { via: RoadtripVia }),
/**
* Lay a chain of vias on one day in one write. Anything that derives its anchors from a
* line produces dozens of them, and one request each would re-route the whole trip once
* per point at better than a second apart.
*/
addVias: (tripId: number | string, dayId: number | string, body: RoadtripViaBatchRequest) =>
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias/batch`, body).then(r => r.data as { vias: RoadtripVia[] }),
/**
* Re-pin a day's vias in one write, after its stops changed shape. One request, not one
* per via: the anchors are only correct as a set.
*/
reanchorVias: (tripId: number | string, dayId: number | string, body: RoadtripViaReanchorRequest) =>
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { vias: RoadtripVia[] }),
moveVia: (tripId: number | string, dayId: number | string, id: number, body: RoadtripViaUpdateRequest) =>
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`, body).then(r => r.data as { via: RoadtripVia }),
removeVia: (tripId: number | string, dayId: number | string, id: number) =>
apiClient.delete(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`).then(r => r.data),
}
export const airportsApi = {
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
@@ -1219,11 +1424,13 @@ export const shareApi = {
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
}
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
// Public transit routing (#1065) — proxied through the server, which picks the
// backend (Transitous/MOTIS, or Google since #1699). `lang` reaches the Google
// backend as the languageCode, so station names come back in the user's script.
export const transitApi = {
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number; lang?: string }) =>
apiClient.get('/transit/plan', { params }).then(r => r.data),
}
+8 -5
View File
@@ -59,23 +59,26 @@ describe('collectionsApi', () => {
expect(res.collection.name).toBe('Tokyo')
})
it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => {
server.use(http.post(BASE, record({ collection })))
// Both handlers answer with the BARE collection, the way the controller does —
// stubbing a { collection } envelope here is what let the client keep reading
// `.collection` off a response that never had one.
it('FE-API-COLLECTIONS-003: create() posts the create payload and returns the list itself', async () => {
server.use(http.post(BASE, record(collection)))
const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' })
expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' })
expect(res.collection.id).toBe(1)
expect(res.id).toBe(1)
})
it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => {
server.use(http.patch(`${BASE}/:id`, record({ collection })))
server.use(http.patch(`${BASE}/:id`, record(collection)))
const res = await collectionsApi.update(1, { name: 'Tokyo 2026' })
expect(requestUrl).toContain(`${BASE}/1`)
expect(requestBody).toEqual({ name: 'Tokyo 2026' })
expect(res.collection).toEqual(collection)
expect(res).toEqual(collection)
})
it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => {
+6 -2
View File
@@ -53,9 +53,13 @@ export const collectionsApi = {
ax.get(base).then((r: AxiosResponse) => r.data),
get: (id: number): Promise<CollectionDetailResponse> =>
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
// Both answer with the bare collection, not a { collection } envelope — the
// controller returns the service's `Collection` straight through, and the e2e
// suite pins that shape. Declaring the envelope made createCollection() resolve
// undefined for every caller.
create: (body: CollectionCreateRequest): Promise<Collection> =>
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
update: (id: number, body: CollectionUpdateRequest): Promise<Collection> =>
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
postMultipart(`${base}/${id}/cover`, formData),
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { useAuthStore } from '../store/authStore'
import { recordPlacePick } from './placeShadow'
import type { PlaceShadowPickRequest } from '@trek/shared'
/**
* The point of this module is that it never gets in the way: it must not fire
* when the instance has the log switched off, must not throw when the request
* fails, and must not send coordinates finer than the ranking needs.
*/
const PICK: PlaceShadowPickRequest = {
query: ' losteria rostock ',
lang: 'de',
biasLat: 54.088712,
biasLng: 12.140493,
source: 'search:nominatim',
liveRank: 0,
liveCount: 8,
pickedName: "L'Osteria",
pickedLat: 54.0891234,
pickedLng: 12.1372987,
pickedPlaceId: 'osm:node/1',
}
/** Resolves with the body the module posted, or null if it never posted. */
function capture(status = 200) {
let seen: unknown = null
let resolve: (v: unknown) => void = () => {}
const posted = new Promise<unknown>(r => { resolve = r })
server.use(
http.post('/api/place-shadow/pick', async ({ request }) => {
seen = await request.json()
resolve(seen)
return status === 200
? HttpResponse.json({ recorded: true })
: HttpResponse.json({ error: 'nope' }, { status })
}),
)
return { posted, seen: () => seen }
}
beforeEach(() => {
useAuthStore.setState({ placeShadowEnabled: true })
})
describe('recordPlacePick', () => {
it('sends nothing at all when the instance has the log switched off', async () => {
useAuthStore.setState({ placeShadowEnabled: false })
const cap = capture()
recordPlacePick(PICK)
await new Promise(r => setTimeout(r, 20))
expect(cap.seen()).toBeNull()
})
it('trims the query and rounds every coordinate to three decimals', async () => {
const cap = capture()
recordPlacePick(PICK)
expect(await cap.posted).toMatchObject({
query: 'losteria rostock',
pickedLat: 54.089,
pickedLng: 12.137,
biasLat: 54.089,
biasLng: 12.14,
})
})
it('leaves an absent bias absent rather than sending a zero', async () => {
const cap = capture()
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
const body = (await cap.posted) as Record<string, unknown>
expect(body.biasLat).toBeUndefined()
expect(body.biasLng).toBeUndefined()
})
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
const cap = capture()
recordPlacePick({ ...PICK, query: ' ' })
recordPlacePick({ ...PICK, pickedName: '' })
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
await new Promise(r => setTimeout(r, 20))
expect(cap.seen()).toBeNull()
})
it('does not record a pick made out of the offline cache', async () => {
// Such a row describes the local cache's ordering, not a provider's, and
// the two figures this corpus exists for — how often the live answer was
// first, and how often it was in the top five — are averaged over every
// row. Mixing these in moves exactly the number a candidate index is later
// judged against. Not merely an offline concern either: offline is a
// setting, so the network is usually reachable and the POST would land.
let posted = false
server.use(http.post('/api/place-shadow/pick', () => { posted = true; return HttpResponse.json({ recorded: true }) }))
recordPlacePick({ ...PICK, source: 'search:offline-cache' })
recordPlacePick({ ...PICK, source: 'autocomplete:offline-cache' })
await new Promise(r => setTimeout(r, 20))
expect(posted).toBe(false)
})
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
const cap = capture(500)
const onUnhandled = vi.fn()
process.on('unhandledRejection', onUnhandled)
expect(() => recordPlacePick(PICK)).not.toThrow()
await cap.posted
await new Promise(r => setTimeout(r, 20))
process.off('unhandledRejection', onUnhandled)
expect(onUnhandled).not.toHaveBeenCalled()
})
})
+51
View File
@@ -0,0 +1,51 @@
import type { PlaceShadowPickRequest } from '@trek/shared'
import { apiClient } from './client'
import { useAuthStore } from '../store/authStore'
/**
* Records which search result the user picked, for the local shadow corpus.
*
* Fire and forget in the strongest sense: it never throws, never blocks the
* pick it is describing, and does nothing at all unless the instance switched
* the log on. Adding a place must not get slower or more fragile because
* somebody is collecting a measurement.
*
* The store flag is checked first so a switched-off instance makes no request
* at all; the server checks it again, because a client is not a permission.
*/
/** About 100 m. Enough to reproduce a ranking, too coarse to place someone. */
function coarse(value: number): number {
return Math.round(value * 1000) / 1000
}
export function recordPlacePick(pick: PlaceShadowPickRequest): void {
if (!useAuthStore.getState().placeShadowEnabled) return
if (!Number.isFinite(pick.pickedLat) || !Number.isFinite(pick.pickedLng)) return
if (pick.liveRank >= pick.liveCount) return
// A pick out of the offline cache describes the cache's ordering, not a
// provider's, and the two numbers this corpus exists for — how often the live
// answer was first, and how often it was in the top five — are averaged over
// every row. Mixing local rows in would move exactly the figure a candidate
// index is later judged against.
//
// Not merely an offline concern: the offline switch is a setting, so the
// network is usually reachable and the POST would go through.
if (pick.source.endsWith('offline-cache')) return
const body: PlaceShadowPickRequest = {
...pick,
query: pick.query.trim().slice(0, 200),
pickedName: pick.pickedName.slice(0, 300),
pickedLat: coarse(pick.pickedLat),
pickedLng: coarse(pick.pickedLng),
biasLat: pick.biasLat === undefined ? undefined : coarse(pick.biasLat),
biasLng: pick.biasLng === undefined ? undefined : coarse(pick.biasLng),
}
// The contract requires both; an unnamed result is not worth a row anyway.
if (!body.query || !body.pickedName) return
void apiClient.post('/place-shadow/pick', body).catch(() => {
// Deliberately silent. The user was adding a place, not running a study.
})
}
@@ -2459,3 +2459,85 @@ describe('AdminPluginsPanel — instance settings', () => {
expect(save.className).not.toContain('text-white')
})
})
/**
* TREK_PLUGINS_IGNORE_TREK_RANGE. The server turns its version gates into warnings; the
* panel's job is to make sure the admin actually SEES the warning — before a registry
* install, after a sideload/link that could not be asked first, and on the row for as
* long as the plugin keeps running outside the range its author vouched for.
*/
describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
function bypassPanel(plugins: Record<string, unknown>[], entry: Record<string, unknown> | null) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, ignoreTrekRange: true, plugins })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
)
}
const incompatibleEntry = () => registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
it('FE-PLG-BYPASS-001: says so in the header', async () => {
bypassPanel([plugin()], null)
render(<AdminPluginsPanel />)
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
})
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
let posted: unknown = null
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
posted = await request.json()
return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' } })
}))
withToast()
fireEvent.click(await screen.findByText('Discover'))
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
expect(btn).toBeEnabled()
fireEvent.click(btn)
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
expect(within(dialog).getByText(/corrupt trek data/i)).toBeInTheDocument()
expect(posted).toBeNull() // nothing sent until the admin accepts the risk
fireEvent.click(within(dialog).getByRole('button', { name: /^install anyway$/i }))
await waitFor(() => expect(posted).toEqual({ id: 'trek-gotify' }))
})
it('FE-PLG-BYPASS-003: cancelling the warning installs nothing', async () => {
let posted = false
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
withToast()
fireEvent.click(await screen.findByText('Discover'))
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
await waitFor(() => expect(screen.queryByRole('dialog', { name: /outside its supported trek versions/i })).not.toBeInTheDocument())
expect(posted).toBe(false)
})
it('FE-PLG-BYPASS-004: a sideload that landed outside its range is warned about afterwards', async () => {
bypassPanel([plugin()], null)
server.use(http.post('*/api/admin/plugins/upload', () =>
HttpResponse.json({ id: 'trek-new', version: '1.0.0', replaced: false, trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' } })))
const { container } = withToast()
await screen.findByText('Gotify')
await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, new File(['zip'], 'plugin.zip', { type: 'application/zip' }))
const dialog = await screen.findByRole('dialog', { name: /installed outside its supported trek versions/i })
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
expect(within(dialog).getByText(/trek-new/)).toBeInTheDocument()
})
it('FE-PLG-BYPASS-005: a plugin running outside its range keeps a warning chip on its row', async () => {
bypassPanel([plugin({
dependencyStatus: 'ok', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0',
trekRangeBypassed: { trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' },
})], null)
render(<AdminPluginsPanel />)
expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument()
})
})
@@ -12,6 +12,7 @@ import {
import PluginIcon from '../shared/PluginIcon'
import { adminApi } from '../../api/client'
import { useInstanceSettings } from './useInstanceSettings'
import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from './useRangeBypass'
import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -64,6 +65,8 @@ interface PluginRow {
trekRange?: string | null
/** The TREK this server is running — the server does the semver, the client just shows it. */
hostVersion?: string
/** Non-null while the plugin runs outside its declared range on the operator's say-so. */
trekRangeBypassed?: TrekRangeBypass | null
/** The author's signature was verified and their key pinned at install. False means the
* bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
signed?: boolean
@@ -273,7 +276,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
return out
}
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean }
// A plugin's declared dependencies as chips — a required addon (amber when that
// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
@@ -291,6 +294,8 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
blocked: true,
})
}
const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker
if (bypassed) out.push(bypassed)
for (const a of p.dependencies?.requiredAddons ?? []) {
out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
}
@@ -310,11 +315,12 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
* has outrun this TREK but an older one still fits, offer THAT version rather than a dead
* grey button. The plugin is perfectly usable, just not at its newest.
*/
function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } {
function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } {
if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
const title = item.trek
? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
: t('admin.plugins.dep.trekUnknown')
if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway"
if (item.latestCompatible) {
return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
}
@@ -398,6 +404,8 @@ export default function AdminPluginsPanel() {
const toast = useToast()
const [runtimeOn, setRuntimeOn] = useState(false)
const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK)
const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side
const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell
const [linkPath, setLinkPath] = useState('')
const [plugins, setPlugins] = useState<PluginRow[]>([])
const [loading, setLoading] = useState(true)
@@ -472,9 +480,10 @@ export default function AdminPluginsPanel() {
// (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
void usePluginStore.getState().loadPlugins()
adminApi.plugins()
.then((d: { enabled: boolean; devLink?: boolean; plugins: PluginRow[] }) => {
.then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => {
setRuntimeOn(!!d.enabled)
setDevLink(!!d.devLink)
setIgnoreTrekRange(!!d.ignoreTrekRange)
setPlugins(d.plugins || [])
if ((d.plugins || []).length) {
adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
@@ -540,6 +549,7 @@ export default function AdminPluginsPanel() {
const res = await adminApi.pluginUpload(file)
setView('installed')
toast.success(t('admin.plugins.uploaded', { name: res.id }))
bypass.notice(res.id, res.trekRangeBypassed)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
} finally {
@@ -610,7 +620,11 @@ export default function AdminPluginsPanel() {
const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
return range ? { version: reg.latest, range } : null
}
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
// `warn` is the pre-install confirm for an entry the registry already flagged as
// incompatible; an artifact whose OWN manifest turns out to be out of range (the index
// was only a pre-download filter) is caught by the marker on the response instead.
const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () =>
act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), t('admin.plugins.installed')))
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
// same busy/toast/refresh loop as uploadPlugin; the server gates it.
@@ -623,6 +637,7 @@ export default function AdminPluginsPanel() {
setView('installed')
setLinkPath('')
toast.success(t('admin.plugins.devLinkLinked', { id: res.id }))
bypass.notice(res.id, res.trekRangeBypassed)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
} finally {
@@ -682,9 +697,10 @@ export default function AdminPluginsPanel() {
if (busy === parent.id) return
setBusy(parent.id)
adminApi.pluginInstall(depId, { constraint, withDependencies: true })
.then((r: { installed?: string[]; requiredAddons?: string[] }) => {
.then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
bypass.notice(depId, r?.trekRangeBypassed)
return attemptActivate(parent)
})
// The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
@@ -702,9 +718,10 @@ export default function AdminPluginsPanel() {
const runUpdate = (p: PluginRow, version?: string) => {
setBusy(p.id); setMenu(null)
return adminApi.pluginUpdate(p.id, version)
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
bypass.notice(p.name, r.trekRangeBypassed)
})
.catch(e => {
const { error, code } = errBody(e)
@@ -828,11 +845,19 @@ export default function AdminPluginsPanel() {
<h2 className="text-lg font-semibold tracking-tight text-content">{t('admin.plugins.title')}</h2>
<p className="text-xs mt-1 text-content-muted max-w-xl">{t('admin.plugins.subtitle')}</p>
</div>
{runtimeOn && (
<span className="inline-flex items-center gap-2 shrink-0 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
</span>
)}
<div className="flex items-center gap-2 shrink-0">
{runtimeOn && ignoreTrekRange && (
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-warning bg-warning-soft px-2.5 py-1.5 rounded-full"
title={t('admin.plugins.rangeBypass.pillHint')}>
<AlertTriangle size={12} /> {t('admin.plugins.rangeBypass.pill')}
</span>
)}
{runtimeOn && (
<span className="inline-flex items-center gap-2 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
</span>
)}
</div>
</div>
</div>
@@ -942,7 +967,7 @@ export default function AdminPluginsPanel() {
) : error ? (
<div className="py-14 text-center text-sm text-danger">{t('admin.plugins.loadError')}</div>
) : !runtimeOn ? null : view === 'discover' ? (
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds}
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds} ignoreTrekRange={ignoreTrekRange}
onInstall={install} onOpenDetail={setDetailFor} filtered={anyFilter} />
) : plugins.length === 0 ? (
<EmptyState t={t} onDiscover={openDiscover} />
@@ -988,10 +1013,12 @@ export default function AdminPluginsPanel() {
{/* Registry detail dialog */}
{detailFor && (
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy}
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy} ignoreTrekRange={ignoreTrekRange}
installed={installedIds.has(detailFor.id)} onInstall={install} onClose={() => setDetailFor(null)} />
)}
{bypass.copy && <RangeBypassDialog copy={bypass.copy} t={t} onConfirm={bypass.confirm} onClose={bypass.dismiss} />}
{/* Error-log modal */}
{errorsFor && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
@@ -1424,8 +1451,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-1.5">
{deps.map((d, i) => (
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
d.blocked ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
<d.icon size={12} className={d.blocked ? 'text-warning' : 'text-content-muted'} />{d.label}
d.blocked || d.warn ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} />{d.label}
</span>
))}
</div>
@@ -1535,14 +1562,15 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
)
}
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered, ignoreTrekRange }: {
items: RegistryItem[] | null
onInstall: (id: string, version?: string) => void
onInstall: (id: string, version?: string, warn?: RangeWarning) => void
onOpenDetail: (item: RegistryItem) => void
busy: string | null
t: T
installedIds: Set<string>
filtered: boolean
ignoreTrekRange: boolean
}) {
if (!items) return <div className="py-14 text-center text-sm text-content-faint">{t('common.loading')}</div>
if (items.length === 0) return (
@@ -1555,7 +1583,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 px-4 sm:px-6 pb-5 pt-1">
{items.map(item => {
const installed = installedIds.has(item.id)
const offer = installOffer(item, t)
const offer = installOffer(item, t, ignoreTrekRange)
return (
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
// No press-scale on the card: shrinking it mid-click slides the Install
@@ -1590,7 +1618,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<Download size={11} /> {formatCompactCount(item.downloadCount)}
</span>
)}
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version, offer.warn) }}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
@@ -1620,9 +1648,54 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
: <code className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded">{perm}</code>
}
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
item: RegistryItem; installed: boolean; busy: string | null
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
/**
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
* component rather than ConfirmDialog because the notice mode has no cancel button.
*/
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
}) {
return createPortal(
<div role="presentation" className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="dialog" aria-modal="true" aria-label={title}
className="bg-surface-card border border-edge rounded-2xl w-full max-w-md p-6 shadow-modal" onClick={e => e.stopPropagation()}>
<div className="flex items-start gap-4">
<div className="shrink-0 w-10 h-10 rounded-full bg-warning-soft grid place-items-center">
<AlertTriangle size={20} className="text-warning" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-content">{title}</h3>
<p className="mt-1.5 text-sm text-content-secondary leading-relaxed">{body}</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
{confirm ? (
<>
<button type="button" onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg text-content-secondary border border-edge-secondary transition-colors">
{t('common.cancel')}
</button>
<button type="button" onClick={onConfirm}
className="px-4 py-2 text-sm font-semibold rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
{t('admin.plugins.installAnyway')}
</button>
</>
) : (
<button type="button" onClick={onClose}
className="px-4 py-2 text-sm font-semibold rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">
{t('common.ok')}
</button>
)}
</div>
</div>
</div>,
document.body,
)
}
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange }: {
item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const [detail, setDetail] = useState<RegistryDetail | null>(null)
const [failed, setFailed] = useState(false)
@@ -1645,7 +1718,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
// The detail fetch carries the same compat verdict as the browse list; prefer it once
// it lands (it is keyed to the same entry) and fall back to the grid item until then.
const offer = installOffer(detail ?? item, t)
const offer = installOffer(detail ?? item, t, ignoreTrekRange)
return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
@@ -1667,7 +1740,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
</div>
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
</div>
<button type="button" onClick={() => onInstall(item.id, offer.version)}
<button type="button" onClick={() => onInstall(item.id, offer.version, offer.warn)}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
@@ -43,6 +43,7 @@ type Defaults = {
blur_booking_codes?: boolean
map_tile_url?: string
carto_api_key?: string
routing_base_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
@@ -119,6 +120,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [routingBase, setRoutingBase] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
@@ -128,6 +130,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setRoutingBase(data.routing_base_url || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
@@ -150,6 +153,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'routing_base_url') setRoutingBase('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
@@ -365,6 +369,32 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
</div>
)}
{/* Instance configuration, not a taste, which is why it sits here rather
than in a user's own Map settings. The origin has to appear in the CSP
`connect-src` the server emits at boot, and that list is built from
this default — a value on a personal settings row is read by the route
calculator and then refused by the browser, so the whole app stops
routing with no error it could report. The write route refuses one
from a non-admin for the same reason. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.routingBase')}
<ResetButton field="routing_base_url" />
</label>
<input
type="text"
value={routingBase}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
onBlur={() => save({ routing_base_url: routingBase.trim() })}
placeholder="https://osrm.example.org"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
</div>
)}
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { bypassChip, bypassOffer, rangeWarningCopy, useRangeBypass } from './useRangeBypass'
const t = (k: string, p?: Record<string, unknown>) => (p ? `${k}:${JSON.stringify(p)}` : k)
/**
* The ONE logic path behind the TREK_PLUGINS_IGNORE_TREK_RANGE warning in both admin
* shells — the desktop panel and the phone panel render their own markup over this.
*/
describe('rangeBypass helpers', () => {
it('bypassChip: a warning chip (not a blocker) only for a bypassed row', () => {
expect(bypassChip(null, t)).toBeNull()
expect(bypassChip(undefined, t)).toBeNull()
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' }, t)).toMatchObject({
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
})
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
})
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
expect(offer).toEqual({
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
})
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
})
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
const ranged = { name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0' }
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
confirm: true, title: 'admin.plugins.rangeBypass.title',
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
})
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
})
})
})
describe('useRangeBypass', () => {
it('guard runs straight through without a warning, and parks the run behind one otherwise', () => {
const { result } = renderHook(() => useRangeBypass())
let ran = 0
act(() => result.current.guard(undefined, () => { ran++ }))
expect(ran).toBe(1)
expect(result.current.warning).toBeNull()
act(() => result.current.guard({ name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
expect(ran).toBe(1) // nothing sent until the admin accepts
expect(result.current.copy?.confirm).toBe(true)
act(() => result.current.confirm())
expect(ran).toBe(2)
expect(result.current.warning).toBeNull()
})
it('dismiss drops a parked run; notice opens a plain notice only for a real marker', () => {
const { result } = renderHook(() => useRangeBypass())
let ran = 0
act(() => result.current.guard({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
act(() => result.current.dismiss())
expect(ran).toBe(0)
expect(result.current.warning).toBeNull()
act(() => result.current.notice('trek-new', null))
expect(result.current.warning).toBeNull()
act(() => result.current.notice('trek-new', { trekRange: null, hostVersion: '3.3.0' }))
expect(result.current.warning).toMatchObject({ name: 'trek-new', onConfirm: null })
expect(result.current.copy?.confirm).toBe(false)
act(() => result.current.confirm()) // a notice's confirm is just a close
expect(result.current.warning).toBeNull()
})
})
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
type T = (k: string, p?: Record<string, unknown>) => string
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
/**
* The chip a row carries while it runs outside its declared range on the operator's
* say-so. Not a blocker — the toggle works — but a warning that must outlive the install
* dialog for as long as the plugin runs here. Null when nothing was bypassed.
*/
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
if (!b) return null
return {
icon: AlertTriangle,
label: b.trekRange
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekBypassedUnknown'),
blocked: false,
warn: true,
}
}
/**
* The Install button for a registry entry the server flagged incompatible, once the
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
* regardless, so the button says exactly that — and carries what the warning has to say
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
*/
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
return {
blocked: false,
label: t('admin.plugins.installAnyway'),
title,
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
}
}
/**
* Title + body of the warning, for either mode: a CONFIRM step before a registry install
* the server would otherwise refuse (`onConfirm` set), or a plain NOTICE after a path that
* could not ask first — sideload, dev-link, update, dependency download. The copy is the
* whole point: the admin is accepting that the author never vouched for this TREK and
* that, in rare cases, that can cost them data.
*/
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
const confirm = w.onConfirm !== null
return {
confirm,
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
body: w.trekRange
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
}
}
/**
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning state behind BOTH admin shells (the desktop
* panel and the phone panel render their own dialog/sheet over it). `guard` parks a
* registry install behind the confirm step when the entry was already flagged
* incompatible; `notice` shows the after-the-fact warning for a response that carries the
* server's marker. Whether the switch is ON stays a plain `useState` in each shell: its
* setter is read inside the mount-time refresh, and only a useState setter is something
* the exhaustive-deps rule knows to be stable.
*/
export function useRangeBypass() {
const { t } = useTranslation()
const [warning, setWarning] = useState<RangeWarning | null>(null)
const notice = (name: string, b: TrekRangeBypass | null | undefined) => {
if (b) setWarning({ name, trekRange: b.trekRange, hostVersion: b.hostVersion, onConfirm: null })
}
const guard = (warn: RangeWarning | undefined, run: () => void) => {
if (warn) setWarning({ ...warn, onConfirm: run })
else run()
}
const confirm = () => { const w = warning; setWarning(null); w?.onConfirm?.() }
const dismiss = () => setWarning(null)
return {
warning,
copy: warning ? rangeWarningCopy(warning, t) : null,
notice, guard, confirm, dismiss,
}
}
@@ -295,6 +295,29 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
})
// #2225 follow-up — the ledger stopped charging payer-less expenses, but this
// tile kept counting them, so it contradicted the balances right beside it.
it('keeps a payer-less expense out of Your share, like the balances do', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] }
const noPayer = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 40, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, noPayer] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Taxi')
// "Your share" is the first of the two bold figures in the Total spend card's
// footer; the second is "You paid". 60 from the paid expense only — the 40
// nobody has paid for is not a share, though it still counts as spend.
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')!
const figures = [...card.querySelectorAll('b')].map(b => b.textContent ?? '')
expect(figures[0]).toContain('60')
expect(figures[0]).not.toContain('100')
})
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
let posted: Record<string, unknown> | null = null
+7 -4
View File
@@ -123,7 +123,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// ── derived expense maths (everything converted to the base currency) ────
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in. A negative
// total (a refund, #2176) is just as unfinished until its recipient is named.
const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0
const myShareOf = (e: BudgetItem) => {
// Nobody paid, so nobody owes: the ledger skips these entirely (#2225), and
// counting them here left the tile contradicting the balances right beside it.
if (isUnfinished(e)) return 0
const myMember = (e.members || []).find(m => m.user_id === me)
if (!myMember) return 0
if (myMember.amount !== null && myMember.amount !== undefined) {
@@ -133,10 +140,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const myShare = shares[me] || 0
return convert(myShare, curOf(e))
}
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in. A negative
// total (a refund, #2176) is just as unfinished until its recipient is named.
const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0
const totals = useMemo(() => {
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
@@ -1275,7 +1275,7 @@ describe('CollabNotes', () => {
});
});
// FE-W5CNT-001 to FE-W5CNT-029
// FE-W5CNT-001 to FE-W5CNT-036
// Fills in the load/error/attachment/category branches of useCollabNotes and the
// view modal that the smoke tests above do not reach.
@@ -1947,4 +1947,32 @@ describe('CollabNotes details', () => {
expect(full.querySelector(`[id="${href.slice(1)}"]`)).not.toBeNull();
expect(ref).not.toHaveAttribute('target');
});
it('FE-W5CNT-035: the expanded note links its website out to a new tab', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 9, title: 'Hotel', website: 'www.hotel.test' })] });
server.use(http.get('/api/trips/1/collab/link-preview', () => HttpResponse.json({ title: 'Hotel', image: null })));
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Hotel');
await user.click(screen.getByTitle('collab.notes.expand'));
const full = await openedNoteBody();
// A member without collab_edit never opens the edit modal, so this is the
// only place the link is readable for them (#2222).
const link = within(full).getByRole('link');
expect(link).toHaveAttribute('href', 'https://www.hotel.test');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(link).toHaveTextContent('hotel.test');
});
it('FE-W5CNT-036: a javascript: website stays unlinked in the expanded note', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 10, title: 'Hotel', website: 'javascript:alert(1)' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Hotel');
await user.click(screen.getByTitle('collab.notes.expand'));
const full = await openedNoteBody();
expect(within(full).queryAllByRole('link')).toHaveLength(0);
});
});
+22 -1
View File
@@ -4,7 +4,7 @@ import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
import { createPortal } from 'react-dom'
import { Plus, Pencil, X, StickyNote, Settings } from 'lucide-react'
import { Plus, Pencil, X, StickyNote, Settings, ExternalLink } from 'lucide-react'
import { collabApi } from '../../api/client'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
@@ -21,6 +21,7 @@ import { CategorySettingsModal } from './CollabNotesCategorySettingsModal'
import { NoteCard } from './CollabNotesCard'
import { FilePreviewPortal } from './CollabNotesFilePreviewPortal'
import { AuthedImg } from './CollabNotesAuthedImg'
import { safeExternalHref } from '../../utils/safeUrl'
// ── Main Component ──────────────────────────────────────────────────────────
interface CollabNotesProps {
@@ -393,9 +394,18 @@ function CollabNotesGrid(S: NotesState) {
)
}
/** Label for the link chip: the host is what people recognise, "www." is noise. */
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
}
function ViewNoteModal(S: NotesState) {
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S
if (!viewingNote) return null
// A member without collab_edit only ever gets this modal, so the link has to
// be here too (#2222). Allow-listed like the card tile: the field takes any
// string, and a javascript: one would run in this origin.
const websiteHref = safeExternalHref(viewingNote.website)
return createPortal(
<div
style={{
@@ -447,6 +457,17 @@ function ViewNoteModal(S: NotesState) {
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{viewingNote.content || ''}</Markdown>
{websiteHref && (
<a href={websiteHref} target="_blank" rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 12,
padding: '6px 12px', borderRadius: 999, border: '1px solid var(--border-primary)',
fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600,
color: 'var(--text-primary)', textDecoration: 'none',
}}>
<ExternalLink size={13} color="var(--text-muted)" /> {linkHost(websiteHref)}
</a>
)}
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-049
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-050
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -27,6 +27,10 @@ vi.mock('leaflet', () => {
setView: vi.fn(),
flyTo: vi.fn(),
getZoom: vi.fn(() => 10),
// Leaflet throws "Set map center and zoom first." out of these until the map
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
// takes it away for the window that #2254 fell into.
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
// The photo layer subscribes to zoom/pan and clusters in screen space (#1614).
@@ -58,6 +62,7 @@ import { render, act, fireEvent, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { buildSettings } from '../../../tests/helpers/factories';
import type { Mock } from 'vitest';
import L from 'leaflet';
import JourneyMap from './JourneyMap';
import type { JourneyMapHandle } from './JourneyMap';
@@ -693,4 +698,47 @@ describe('JourneyMap', () => {
expect(html).toHaveLength(1);
expect(html[0]).not.toMatch(/>\d+</);
});
// #2254 — the map effect defers its opening fitBounds/setView to a rAF, so this
// effect can draw while the map still has no center or zoom. Clustering
// projects to container pixels, which throws in that window and took the whole
// public journey page down with it.
it('FE-COMP-JOURNEYMAP-050: waits out a map that has no view yet, then draws on moveend', () => {
const noView = () => { throw new Error('Set map center and zoom first.'); };
let hasView = false;
const photoHtml = () =>
vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
// The mock hands out one shared map instance, so it can be reached — and
// stripped of its view — before the component ever builds one. Typed down to
// the three spies this needs: L.map's signature promises a real Leaflet map.
type MapProbe = { getCenter: Mock; latLngToContainerPoint: Mock; on: Mock };
L.map(document.createElement('div'));
const map = mockedMap() as MapProbe;
vi.mocked(L.map).mockClear();
map.getCenter.mockImplementation(() => (hasView ? { lat: 0, lng: 0 } : noView()));
map.latLngToContainerPoint.mockImplementation(() => (hasView ? { x: 0, y: 0 } : noView()));
try {
// Rendering must survive the viewless window, and draw nothing in it.
expect(() =>
render(
<JourneyMap checkins={[]} entries={[]} photos={[{ id: 'p1', lat: 1, lng: 2, thumbUrl: '/uploads/late.jpg' }]} />,
),
).not.toThrow();
expect(photoHtml()).toHaveLength(0);
// The deferred fitBounds lands: the listener this effect registered redraws.
hasView = true;
const redraw = map.on.mock.calls.find(c => c[0] === 'moveend')?.[1] as () => void;
expect(redraw).toBeTypeOf('function');
act(() => { redraw(); });
expect(photoHtml()).toHaveLength(1);
expect(photoHtml()[0]).toContain('/uploads/late.jpg');
} finally {
map.getCenter.mockImplementation(() => ({ lat: 0, lng: 0 }));
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
}
});
});
@@ -405,6 +405,16 @@ function JourneyMap(
photoLayerRef.current = null
if (!photos?.length) return
// The initial view (setView/fitBounds) is set on a deferred rAF in the
// map-build effect above, so this can run before the map has a center/zoom
// — latLngToContainerPoint throws in that window. Skip; the moveend/zoomend
// listener below redraws once the deferred view lands.
try {
map.getCenter()
} catch {
return
}
const group = L.layerGroup()
for (const cluster of clusterPhotos(map, photos)) {
const marker = L.marker([cluster.lat, cluster.lng], {
+225 -24
View File
@@ -3,7 +3,34 @@ import DOM from 'react-dom'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
/**
* The drive-time pill for one offered route.
*
* A divIcon rather than a tooltip: it has to be clickable, it has to sit exactly on the
* road, and it is the same shape the GL renderers build by hand — one look across all
* three maps.
*/
function alternativeLabelIcon(label: string, note: string, background: string, active: boolean) {
const outline = active ? 'outline:2px solid #fff;outline-offset:1px;' : ''
const second = note
? `<span style="font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2">${escapeHtml(note)}</span>`
: ''
return L.divIcon({
className: '',
// `font-family` spelled out: a divIcon sits inside `.leaflet-container`, whose own
// stylesheet sets Helvetica/Arial on everything in it. Without this the label is the
// one piece of TREK chrome on the map that is not in the app's typeface.
html: `<span style="display:inline-flex;flex-direction:column;align-items:flex-start;white-space:nowrap;background:${background};color:${ALT_LABEL_TEXT};border-radius:11px;padding:6px 11px;font-family:var(--font-system);font-size:13px;font-weight:600;line-height:1.25;box-shadow:0 2px 10px rgba(0,0,0,.35);cursor:pointer;transition:none;${outline}"><span>${escapeHtml(label)}</span>${second}</span>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
})
}
import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
@@ -76,9 +103,21 @@ function formatViaDwell(seconds: number): string {
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumbers, isSelected) {
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}`
const cached = iconCache.get(cacheKey)
if (cached) return cached
// A stop that interrupts the drive is drawn as its own small disc, before the photo
// branch below ever gets a look at it — the brand logo a fuel search comes back with
// is exactly what this replaces. No number badge either, for the same reason the rail
// gives it none: it is part of the drive, not one of the day's stops.
const service = serviceMarkerHtml(place.stop_type, isSelected)
if (service) {
const outer = serviceMarkerOuter(isSelected)
const icon = L.divIcon({ className: '', html: service, iconSize: [outer, outer], iconAnchor: [outer / 2, outer / 2] })
iconCache.set(cacheKey, icon)
return icon
}
const size = isSelected ? 44 : 36
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
// escaping stops the attribute breakout but still permits a CSS url().
@@ -159,7 +198,13 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
// markers of planned places; the colour matches its pill category.
const poiIconCache = new Map<string, L.DivIcon>()
function createPoiIcon(category: string) {
function createPoiIcon(category: string, brandWikidata?: string | null) {
// One flat disc in the category's colour with its icon, and never the chain's logo.
// The brands turned a corridor full of petrol stations into a row of advertisements,
// they were unreadable at pin size, and a brand with no logo on file fell back to a
// different picture entirely — so no two pins looked alike. `brandWikidata` is kept in
// the signature because the callers still have it; it simply no longer changes anything.
void brandWikidata
const cached = poiIconCache.get(category)
if (cached) return cached
const cat = POI_CATEGORY_BY_KEY[category]
@@ -167,7 +212,7 @@ function createPoiIcon(category: string) {
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
const icon = L.divIcon({
className: '',
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
tooltipAnchor: [0, -14],
@@ -193,6 +238,41 @@ function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: bo
return null
}
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
const map = useMap()
useEffect(() => {
if (!onPoiDropOnRoute) return
const container = map.getContainer()
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [map, onPoiDropOnRoute])
return null
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area.
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
@@ -277,9 +357,17 @@ interface BoundsControllerProps {
paddingOpts: L.FitBoundsOptions
/** The map was built already framed on these places, so the opening fit has nothing to do. */
framedOnMount?: boolean
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
* for itself that it means "the selected day". Weighing the ways of driving one leg
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false }: BoundsControllerProps) {
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
@@ -336,6 +424,15 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
if (!focusPoints?.length) return
// A day fit that has not run yet must not overwrite this a moment later.
awaitingRoute.current = false
fitTo(focusPoints)
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
@@ -538,6 +635,18 @@ export const MapView = memo(function MapView({
onViewportChange,
tripId,
routeVias = [],
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
roadtripVias,
onMoveVia,
onRemoveVia,
alternativeRoutes,
focusPoints,
clusterLoosely = false,
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
}: any) {
// The caller hands over whatever the user configured; what kind of basemap
// that is decides which layer draws it. A saved raster template still wins,
@@ -547,13 +656,21 @@ export const MapView = memo(function MapView({
<Marker
key={`poi-${poi.osm_id}`}
position={[poi.lat, poi.lng]}
icon={createPoiIcon(poi.category)}
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
zIndexOffset={500}
eventHandlers={{ click: () => onPoiClick?.(poi) }}
eventHandlers={{
click: () => onPoiClick?.(poi),
// Wired on add rather than in a layout effect: Leaflet builds the icon element
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
const el = e.target.getElement()
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
},
}}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
</Marker>
)), [pois, onPoiClick])
)), [pois, onPoiClick, onPoiDropOnRoute])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
@@ -872,43 +989,127 @@ export const MapView = memo(function MapView({
)}
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
<ViewportController onViewportChange={onViewportChange} />
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
zoomed right out, and a day's stops merging into one dot is exactly the shape
it is being looked at for. Rendered outside the group rather than with the
clustering disabled by option, so nothing is left to a plugin's own idea of
what "disabled" means. */}
{clusterLoosely ? markers : (
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
)}
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
<Polyline
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu
// underneath the new via.
e.originalEvent.stopPropagation()
onRouteClick(e.latlng.lat, e.latlng.lng)
},
} : undefined}
/>,
<Polyline
key={`${i}-core`}
positions={seg}
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [])}
{/* The last bit to a place the road does not reach.
Dashed and thin, over the route rather than under it, because it is the one
piece of the line that is not driving: the router snapped the stop to the
nearest road and the drive really ends there. Same blue as the route, so it
reads as the end of that route and not as a second one. */}
{(accessLines ?? []).map((spur, i) => (
<Polyline
key={`access-${i}`}
positions={spur.line}
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
interactive={false}
/>
))}
{/* The offered ways of driving one leg, over the route they replace. Clicking one
takes it, which is the same choice the bar above the map offers. */}
{/* Every option in blue over a white casing, after Apple Maps: they are all real
roads, so the difference is emphasis, not category. Grey was the first attempt
and it disappeared on Positron, which is almost entirely greys. */}
{(alternativeRoutes ?? []).flatMap((alt: AlternativeOverlay) => {
const active = activeAlternative === alt.index
return [
<Polyline
key={`alt-${alt.index}-casing`}
positions={alt.coordinates}
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
<Polyline
key={`alt-${alt.index}`}
positions={alt.coordinates}
pathOptions={{
color: alt.color,
weight: active ? 6 : 4,
opacity: active ? 1 : 0.9,
lineCap: 'round',
lineJoin: 'round',
}}
eventHandlers={onChooseAlternative ? {
click: (e: { originalEvent: MouseEvent }) => { e.originalEvent.stopPropagation(); onChooseAlternative(alt.index) },
mouseover: () => onHighlightAlternative?.(alt.index),
mouseout: () => onHighlightAlternative?.(null),
} : undefined}
/>,
// The drive time on the road itself, the way Apple labels them.
<Marker
key={`alt-${alt.index}-label`}
position={[alt.at.lat, alt.at.lng]}
zIndexOffset={600}
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
eventHandlers={onChooseAlternative ? {
click: () => onChooseAlternative(alt.index),
mouseover: () => onHighlightAlternative?.(alt.index),
mouseout: () => onHighlightAlternative?.(null),
} : undefined}
/>,
]
})}
{/* The handles that shape the drive. After the route so they sit on top of it. */}
{roadtripVias ? (
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
) : null}
{/* GPX imported route geometries */}
<TrackCasingPane onReady={setHasCasingPane} />
{gpxPolylines}
@@ -1855,4 +1855,104 @@ describe('MapViewGL', () => {
expect(glMap.off).toHaveBeenCalledWith('moveend', expect.any(Function))
expect(glMap.off).toHaveBeenCalledWith('zoomend', expect.any(Function))
})
it('FE-COMP-MAPVIEWGL-071: POI suggestions ride the map render clock instead of the pointer', async () => {
// Same drift the planned-place pins had: a library Marker repositions on every
// `move` event — one per pointer sample — while the canvas draws once a frame, so
// the suggestions swam over the map during a drag and snapped back on release.
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
render(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
pois={[{ osm_id: 'node:1', name: 'Aral', lat: 48.1, lng: 2.1, category: 'fuel', source: 'openstreetmap' } as never]}
/>,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
expect(layer.children.length).toBe(1)
const pin = layer.firstElementChild as HTMLElement
expect(pin.style.transform).toContain('translate(100px, 80px)')
glMap.project.mockReturnValue({ x: 140, y: 60 })
act(() => { mapHandler('render')() })
expect(pin.style.transform).toContain('translate(140px, 60px)')
})
// A via handle is a DOM element, and a pointerdown belongs to the element it happened
// on. Rebuilding the handles on an unrelated render pulled that element out from under
// the pointer, so a freshly placed via lost its first drag — the re-route it triggered
// landed about a second later, which is exactly when somebody reaches for it.
it('FE-COMP-MAPVIEWGL-VIA-001: a via handle survives a render it has nothing to do with', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
const { rerender } = render(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [stored] }}
onMoveVia={() => {}}
/>,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild
expect(handle).toBeTruthy()
// The same via in a fresh object, with a fresh callback: what every render of the
// planner hands down while the day re-routes.
rerender(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [{ ...stored }] }}
onMoveVia={() => {}}
/>,
)
await flushFrames()
expect(layer.firstElementChild).toBe(handle)
})
it('FE-COMP-MAPVIEWGL-VIA-002: a via that actually moved is drawn again', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
const { rerender } = render(
<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" roadtripVias={{ 1: [stored] }} onMoveVia={() => {}} />,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild
rerender(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [{ ...stored, lat: 48.9 }] }}
onMoveVia={() => {}}
/>,
)
await flushFrames()
// The other half of the contract: holding the element still must not mean holding it
// in the wrong place.
expect(layer.firstElementChild).not.toBe(handle)
})
})
+613 -73
View File
@@ -1,5 +1,10 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import type { RoadtripVia } from '@trek/shared'
import { useStableVias } from './viaMarkerState'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
@@ -81,6 +86,8 @@ interface RouteSegment {
// mouseleave never fires" case (#1404).
const NO_PLACES: Place[] = []
const NO_ROUTE_VIAS: RouteVia[] = []
/** Stable empty default: a fresh array each render would refire the spur effect. */
const NO_ACCESS_LINES: { line: [[number, number], [number, number]]; meters: number }[] = []
const NO_ROUTE_SEGMENTS: RouteSegment[] = []
const NO_DAY_ORDER: Record<number, number[] | null> = {}
const NO_RESERVATIONS: Reservation[] = []
@@ -97,6 +104,8 @@ interface Props {
tripId?: number | string
// Charging stops / rest areas a plugin route places on the drawn day route.
routeVias?: RouteVia[]
/** The dashed last bit to a place the road network does not reach. */
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
route?: [number, number][][] | null
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
@@ -121,6 +130,39 @@ interface Props {
onReservationClick?: (reservationId: number) => void
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
/**
* A corridor hit dropped somewhere on the map, with the coordinate it landed on.
* The caller decides whether that point is near enough to the drive to mean anything.
*/
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void
/** A click on the drawn route, for putting a via point there (#1797). */
onRouteClick?: (lat: number, lng: number) => void
/** The ways of driving one leg, drawn while the picker is open. */
alternativeRoutes?: AlternativeOverlay[]
/** Which option is being considered, so it can be lit up in its own colour. */
activeAlternative?: number | null
onChooseAlternative?: (index: number) => void
/** Reports which option the pointer is over, so the list and the map agree. */
onHighlightAlternative?: (index: number | null) => void
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
* for itself that it means "the selected day". Weighing the ways of driving one leg
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/**
* Let markers stay apart longer than usual.
*
* A road trip is read along a line: two stops fifty kilometres apart on the same
* motorway are the shape of the day, and merging them into one dot hides it.
*/
clusterLoosely?: boolean
/** Via points to draw as draggable handles, keyed by day (#1797). */
roadtripVias?: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
onRemoveVia?: (dayId: number, id: number) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
glProvider?: GlMapProvider
/**
@@ -134,7 +176,76 @@ interface Props {
onMapReady?: (map: any | null) => void
}
/** How eagerly place markers merge into a cluster, outside road trip mode. */
const CLUSTER_RADIUS = 30
const CLUSTER_MAX_ZOOM = 10
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addPlaceClusterLayers(map: any): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
// No number badge either, for the same reason the rail gives it none.
const service = serviceMarkerHtml(place.stop_type, selected)
if (service) {
const outer = serviceMarkerOuter(selected)
const wrap = document.createElement('div')
wrap.style.cssText = `width:${outer}px;height:${outer}px;display:flex;align-items:center;justify-content:center`
wrap.innerHTML = service
return wrap
}
const size = selected ? 44 : 36
// See MapView: allow-listed rather than escaped, because this is a CSS context.
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
@@ -356,6 +467,20 @@ function makePlacePin(map: any, layer: HTMLElement, el: HTMLElement, lng: number
}
}
/**
* Puts an element on the map as a pin.
*
* `layer` is only ever set under MapLibre (that is where it gets created), so passing it
* along is the whole provider decision: with a layer we position the element ourselves on
* the map's render clock, without one the library's own Marker does it.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function attachPin(map: any, gl: any, layer: HTMLElement | null, el: HTMLElement, lng: number, lat: number): PlacePin {
return layer
? makePlacePin(map, layer, el, lng, lat)
: libraryPin(new gl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map))
}
// Tone dot for a plugin marker — visual twin of MapPluginMarkers' divIcon.
function createPluginMarkerElement(tone: PluginMapMarker['tone']): HTMLDivElement {
const color = PLUGIN_TONE_COLORS[tone] ?? PLUGIN_TONE_COLORS.default
@@ -395,21 +520,37 @@ function buildPluginMarkerPopup(mk: PluginMapMarker): HTMLDivElement {
}
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
function createPoiMarkerElement(category: string): HTMLDivElement {
// A chain shows its logo instead of the category icon: on a corridor full of petrol
// stations the brand is what the eye is looking for, and the server proxies it so the
// browser never asks Wikimedia which ones are on screen.
function createPoiMarkerElement(category: string, brandWikidata?: string | null): HTMLDivElement {
const cat = POI_CATEGORY_BY_KEY[category]
const color = cat?.color || '#6b7280'
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
const el = document.createElement('div')
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
el.style.cssText = 'width:26px;height:26px;cursor:pointer;will-change:transform;'
el.innerHTML = `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;overflow:hidden;">${svg}${brandLogoMarkup(brandWikidata)}</div>`
return el
}
/**
* Kept as the one place that decides a POI pin carries no brand mark: nothing.
*
* The logos turned a corridor full of petrol stations into a row of advertisements, were
* unreadable at pin size, and a brand with no logo on file fell back to a different
* picture entirely — so no two pins looked alike. A flat disc in the category's colour
* with its icon is what the rail, the corridor list and the map all show now.
*/
export function brandLogoMarkup(_brandWikidata?: string | null): string {
return ''
}
export function MapViewGL({
places = NO_PLACES,
dayPlaces = NO_PLACES,
tripId,
routeVias = NO_ROUTE_VIAS,
accessLines = NO_ACCESS_LINES,
route = null,
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
@@ -420,6 +561,8 @@ export function MapViewGL({
center = DEFAULT_MAP_CENTER,
zoom = DEFAULT_MAP_ZOOM,
fitKey = 0,
focusPoints,
clusterLoosely = false,
dayOrderMap = NO_DAY_ORDER,
leftWidth = 0,
rightWidth = 0,
@@ -434,6 +577,15 @@ export function MapViewGL({
onReservationClick,
pois = NO_POIS,
onPoiClick,
onPoiDropOnRoute,
onRouteClick,
alternativeRoutes,
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
roadtripVias,
onMoveVia,
onRemoveVia,
onViewportChange,
glProvider = 'mapbox-gl',
gl,
@@ -480,31 +632,336 @@ export function MapViewGL({
const markersRef = useRef<Map<number, PlacePin>>(new Map())
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
const pinLayerRef = useRef<HTMLDivElement | null>(null)
const repositionPins = useCallback(() => {
markersRef.current.forEach(pin => pin.reposition())
}, [])
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poiMarkersRef = useRef<any[]>([])
const poiMarkersRef = useRef<PlacePin[]>([])
/** Undoes the drag wiring on each POI pin; the pins themselves are rebuilt wholesale. */
const poiCleanupRef = useRef<(() => void)[]>([])
const onPoiDropRef = useRef(onPoiDropOnRoute)
onPoiDropRef.current = onPoiDropOnRoute
// Plugin map contributions — data fetched per trip, elements owned imperatively
// like the POI markers so they survive the React render cycle.
const [pluginMarkers, setPluginMarkers] = useState<PluginMapMarker[]>([])
const [pluginLayers, setPluginLayers] = useState<PluginMapLayer[]>([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginMarkersRef = useRef<any[]>([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const routeViaMarkersRef = useRef<any[]>([])
const pluginMarkersRef = useRef<PlacePin[]>([])
const routeViaMarkersRef = useRef<PlacePin[]>([])
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
const viaPinsRef = useRef<PlacePin[]>([])
/** The drive-time pills on the offered routes; same treatment. */
const altLabelsRef = useRef<PlacePin[]>([])
// Every hand-positioned pin, whichever set it belongs to. They all have to be written
// in the same frame the canvas draws, or the ones left out swim against the ones in.
const repositionPins = useCallback(() => {
markersRef.current.forEach(pin => pin.reposition())
poiMarkersRef.current.forEach(pin => pin.reposition())
pluginMarkersRef.current.forEach(pin => pin.reposition())
routeViaMarkersRef.current.forEach(pin => pin.reposition())
viaPinsRef.current.forEach(pin => pin.reposition())
altLabelsRef.current.forEach(pin => pin.reposition())
}, [])
// Single reusable hover popup for POI markers. Planned places use the
// cursor-following React tooltip below so they match the Leaflet map.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const popupRef = useRef<any | null>(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
/**
* The via handles.
*
* Built as plain elements and attached with the same `attachPin` the place pins use,
* rather than the library's own marker: under MapLibre those float above the map during
* a drag instead of sticking to the ground (the reason `attachPin` exists at all). The
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
*/
const viaCleanupRef = useRef<(() => void)[]>([])
/**
* The list only changes when a via does, and the callbacks are read through a ref.
*
* Both matter for the same reason: this effect destroys every handle and builds new DOM
* elements. It used to re-run on every render of the planner — the vias arrived as a
* fresh object each time and `onMoveVia` as a fresh closure — so the render that landed
* when a freshly placed via finished re-routing pulled the element out from under the
* pointer, and the first drag went nowhere.
*/
const vias = useStableVias(roadtripVias)
const viaHandlersRef = useRef({ onMoveVia, onRemoveVia })
viaHandlersRef.current = { onMoveVia, onRemoveVia }
const viasDraggable = !!onMoveVia
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
viaCleanupRef.current.forEach(off => off())
viaCleanupRef.current = []
viaPinsRef.current.forEach(p => p.remove())
viaPinsRef.current = []
for (const via of vias) {
const el = document.createElement('span')
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat)
viaPinsRef.current.push(pin)
if (!viasDraggable) continue
let dragging = false
// The map starts panning on mousedown/touchstart anywhere on its surface, and a pin
// sits on that surface. Stopping only pointerdown left both running at once: the
// handle moved AND the map slid out from under it.
const swallow = (e: Event) => e.stopPropagation()
// Where the gesture began, so a click can be told from a drag. Without it
// every pointerup committed a move to wherever inside the 17px handle the
// pointer happened to be: at a low zoom that is kilometres, and the write
// re-routes the whole trip. Leaflet gets this for free from `dragend`,
// which is why the two renderers behaved differently for one gesture.
let startedAt: { x: number; y: number } | null = null
const MOVED_ENOUGH = 4
const onDown = (e: PointerEvent) => {
// Primary button only. Right-clicking ran onDown, then contextmenu ->
// remove AND pointerup -> move, so one gesture sent two writes; on the
// platforms where contextmenu comes first, the move landed on an id that
// no longer existed and reported a failure for a deletion that worked.
if (e.button !== 0) return
e.stopPropagation()
e.preventDefault()
dragging = true
startedAt = { x: e.clientX, y: e.clientY }
el.setPointerCapture(e.pointerId)
el.style.cursor = 'grabbing'
}
const onMove = (e: PointerEvent) => {
if (!dragging) return
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
pin.setLngLat([at.lng, at.lat])
}
const onUp = (e: PointerEvent) => {
if (!dragging) return
dragging = false
el.style.cursor = 'grab'
const from = startedAt
startedAt = null
const travelled = from ? Math.hypot(e.clientX - from.x, e.clientY - from.y) : 0
if (travelled < MOVED_ENOUGH) {
// A click, not a drag. Put the pin back where the via actually is: the
// move handler has been following the pointer across the handle.
pin.setLngLat([via.lng, via.lat])
return
}
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
}
// The browser fires this when the captured element is torn out of the DOM,
// which is exactly what a delete does. Committing a position there wrote a
// move for a via that was being removed.
const onCancel = () => {
if (!dragging) return
dragging = false
startedAt = null
el.style.cursor = 'grab'
pin.setLngLat([via.lng, via.lat])
}
const onContext = (e: MouseEvent) => {
e.preventDefault()
viaHandlersRef.current.onRemoveVia?.(via.day_id, via.id)
}
el.addEventListener('pointerdown', onDown)
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
el.addEventListener('pointercancel', onCancel)
el.addEventListener('contextmenu', onContext)
el.addEventListener('mousedown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('dblclick', swallow)
viaCleanupRef.current.push(() => {
el.removeEventListener('pointerdown', onDown)
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
el.removeEventListener('pointercancel', onCancel)
el.removeEventListener('contextmenu', onContext)
el.removeEventListener('mousedown', swallow)
el.removeEventListener('touchstart', swallow)
el.removeEventListener('dblclick', swallow)
})
}
}, [vias, viasDraggable, mapReady, glProvider])
/**
* The offered routes, drawn under the current one.
*
* Grey and dashed rather than a set of bright lines: at any moment one of them IS the
* route, drawn in blue on top, and three more solid colours next to it would read as
* four equal roads. Hovering an option in the bar lights that one up in its own colour,
* which is the only moment a second colour helps.
*/
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const source = map.getSource?.('route-alternatives')
if (!source?.setData) return
source.setData({
type: 'FeatureCollection',
features: (alternativeRoutes ?? []).map((alt, i) => ({
type: 'Feature',
properties: { index: i, active: activeAlternative === i, color: alt.color },
geometry: { type: 'LineString', coordinates: alt.coordinates.map(([lat, lng]) => [lng, lat]) },
})),
})
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
/**
* The drive time on each offered route, the way Apple Maps labels them.
*
* Hand-positioned pins rather than a symbol layer: a symbol would need the text in the
* style's own font stack, which differs between the bundled MapLibre styles and Mapbox,
* and the pill has a shape a text layer cannot draw. They join the render clock like
* every other pin here, so they stay on their road while the map moves.
*/
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
altLabelsRef.current.forEach(p => p.remove())
altLabelsRef.current = []
for (const alt of alternativeRoutes ?? []) {
const el = document.createElement('button')
el.type = 'button'
const active = activeAlternative === alt.index
el.style.cssText = [
// A rounded rectangle rather than a pill, and roomy enough for a second line —
// the shape Apple gives these, and the reason "1 h 55 min / Fastest" reads as one
// label instead of two stacked chips.
'display:flex;flex-direction:column;align-items:flex-start;white-space:nowrap',
`background:${alt.labelBg}`,
`color:${ALT_LABEL_TEXT}`,
'border:none;border-radius:11px',
// A <button> does not inherit the page's font, so without this the label reads in
// the browser's UI face while every other piece of TREK chrome reads in Poppins.
'font-family:var(--font-system)',
'padding:6px 11px;font-size:13px;font-weight:600;line-height:1.25',
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
'cursor:pointer;pointer-events:auto',
// The pin is positioned by writing `transform` every frame. Any inherited
// transition on it makes the label chase the map instead of sticking to the road,
// and `transform` is exactly what a button picks up from the app's own styles.
'transition:none !important;animation:none !important',
'will-change:transform;backface-visibility:hidden',
active ? 'outline:2px solid #fff;outline-offset:1px' : '',
].filter(Boolean).join(';')
const time = document.createElement('span')
time.textContent = alt.label
el.appendChild(time)
if (alt.note) {
const note = document.createElement('span')
note.textContent = alt.note
note.style.cssText = 'font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2'
el.appendChild(note)
}
// Same reason as the via handles: without this the map pans as the label is pressed.
el.addEventListener('mousedown', e => e.stopPropagation())
el.addEventListener('touchstart', e => e.stopPropagation(), { passive: true })
el.addEventListener('click', e => { e.stopPropagation(); onChooseAlternativeRef.current?.(alt.index) })
el.addEventListener('mouseenter', () => onHighlightAlternativeRef.current?.(alt.index))
el.addEventListener('mouseleave', () => onHighlightAlternativeRef.current?.(null))
altLabelsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, alt.at.lng, alt.at.lat))
}
return () => {
altLabelsRef.current.forEach(p => p.remove())
altLabelsRef.current = []
}
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
/** Clicking one of the offered routes takes it, the same as clicking its chip. */
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || !onChooseAlternative) return
const onClick = (e: { features?: { properties?: { index?: number } }[] }) => {
const index = e.features?.[0]?.properties?.index
if (typeof index === 'number') onChooseAlternative(index)
}
const enter = () => { map.getCanvas().style.cursor = 'pointer' }
const leave = () => { map.getCanvas().style.cursor = '' }
if (!map.getLayer?.('route-alt-hit')) return
map.on('click', 'route-alt-hit', onClick)
map.on('mouseenter', 'route-alt-hit', enter)
map.on('mouseleave', 'route-alt-hit', leave)
return () => {
map.off('click', 'route-alt-hit', onClick)
map.off('mouseenter', 'route-alt-hit', enter)
map.off('mouseleave', 'route-alt-hit', leave)
}
}, [mapReady, onChooseAlternative])
/**
* A click on the route line, which in a GL renderer is a layer rather than an element.
* `map.on(type, layerId, …)` is the one way to hit it, and it fires before the map's own
* click, so the handler can keep the add-place menu from opening underneath the via.
*/
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || !onRouteClick) return
const onClick = (e: { lngLat: { lat: number; lng: number }; point?: { x: number; y: number }; preventDefault?: () => void }) => {
e.preventDefault?.()
// Layer handlers are independent: MapLibre evaluates each registration
// against the same click, so a point that hits both the offered-route band
// and the current-route band fires both. Every alternative starts and ends
// at the same two stops, and when the leg already carries vias the current
// road is itself one of the offers, so the overlap is the whole leg. One
// click then dropped a via AND chose a different road, two writes and two
// trip reloads for a gesture the user made once. The picker is a modal
// choice about that leg; dropping a via mid-choice is not offered anywhere.
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
onRouteClick(e.lngLat.lat, e.lngLat.lng)
}
const enter = () => { map.getCanvas().style.cursor = 'copy' }
const leave = () => { map.getCanvas().style.cursor = '' }
for (const layer of ['trip-route-hit']) {
if (!map.getLayer?.(layer)) continue
map.on('click', layer, onClick)
map.on('mouseenter', layer, enter)
map.on('mouseleave', layer, leave)
}
return () => {
for (const layer of ['trip-route-hit']) {
map.off('click', layer, onClick)
map.off('mouseenter', layer, enter)
map.off('mouseleave', layer, leave)
}
}
}, [mapReady, onRouteClick])
/**
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
* canvas and has no element to aim at. The coordinate under the pointer says just as
* much once it is projected onto the routed geometry, and it works the same in all
* three renderers.
*/
useEffect(() => {
const map = mapRef.current
const container = map?.getContainer?.()
if (!container || !mapReady || !onPoiDropOnRoute) return
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [mapReady, onPoiDropOnRoute])
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
const onMapReadyRef = useRef(onMapReady)
@@ -516,6 +973,13 @@ export function MapViewGL({
onClickRefs.current.context = onMapContextMenu
const hoverDisabledRef = useRef(hoverDisabled)
hoverDisabledRef.current = hoverDisabled
// Read inside the map's own click handler, which is registered once at setup.
const onRouteClickRef = useRef(onRouteClick)
onRouteClickRef.current = onRouteClick
const onChooseAlternativeRef = useRef(onChooseAlternative)
onChooseAlternativeRef.current = onChooseAlternative
const onHighlightAlternativeRef = useRef(onHighlightAlternative)
onHighlightAlternativeRef.current = onHighlightAlternative
// Same gate as the Leaflet renderer: HTML5 drag is a pointer feature, and the
// day plan the marker would be dropped on is not on screen on a phone anyway.
const markersDraggableRef = useRef(typeof window !== 'undefined' && navigator.maxTouchPoints === 0)
@@ -599,6 +1063,49 @@ export function MapViewGL({
if (glStyle === MAPBOX_DEFAULT_STYLE) {
try { map.setTerrain(null) } catch { /* noop */ }
}
// The ways of driving one leg, offered while the picker is open (#1797). Added
// BEFORE the route source so its layers sit underneath: the current route stays the
// brightest thing on the map, and the options read as what they are — suggestions.
if (!map.getSource('route-alternatives')) {
map.addSource('route-alternatives', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
// A wide invisible band per option, so a pointer can pick one without hitting
// the 3px line exactly.
map.addLayer({
id: 'route-alt-hit',
type: 'line',
source: 'route-alternatives',
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 22 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// A white casing under the option lines, the same trick the main route uses.
// Plain grey disappeared on a grey basemap — Positron is mostly greys, and a
// grey dashed line on it is invisible. The casing gives every option an edge, so
// it reads on a pale map, a dark one and a satellite tile alike.
map.addLayer({
id: 'route-alt-casing',
type: 'line',
source: 'route-alternatives',
paint: { 'line-color': ALT_CASING, 'line-width': 8, 'line-opacity': 0.9 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'route-alt-line',
type: 'line',
source: 'route-alternatives',
// Dark and dashed by default so it stays subordinate to the blue route it is an
// alternative to; the one being considered takes its own colour and thickens.
// Every option in blue, after Apple Maps: they are all real roads, so the
// difference between them is emphasis rather than category. The one being
// considered thickens; nothing turns grey, because grey vanishes on Positron.
paint: {
'line-color': ['coalesce', ['get', 'color'], '#7eb8f0'],
'line-width': ['case', ['boolean', ['get', 'active'], false], 6, 4],
'line-opacity': ['case', ['boolean', ['get', 'active'], false], 1, 0.9],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
// initial route source — kept around so updates can setData() cheaply
if (!map.getSource('trip-route')) {
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
@@ -611,6 +1118,16 @@ export function MapViewGL({
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// An invisible band over the route, purely to be clicked. The drawn line is 8px
// and a pointer is not that accurate, so aiming at the road was most of why
// putting a via there felt like it did not work.
map.addLayer({
id: 'trip-route-hit',
type: 'line',
source: 'trip-route',
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 26 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'trip-route-line',
type: 'line',
@@ -619,6 +1136,21 @@ export function MapViewGL({
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
// The last bit to a place the road does not reach (#1797). Added after the route
// so it draws over it: it is the one piece of the line that is not driving, and it
// has to be readable exactly where it meets the road it leaves. Same blue, because
// it is the end of that route rather than a second one; dashed short and thin,
// which is how a map says "on foot from here" without a legend.
if (!map.getSource('trip-access')) {
map.addSource('trip-access', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({
id: 'trip-access-line',
type: 'line',
source: 'trip-access',
paint: { 'line-color': '#0a84ff', 'line-width': 3, 'line-opacity': 0.85, 'line-dasharray': [1, 2.5] },
layout: { 'line-cap': 'round' },
})
}
// gpx geometries source (place.route_geometry)
if (!map.getSource('trip-gpx')) {
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
@@ -681,53 +1213,7 @@ export function MapViewGL({
map.on('mouseleave', GPX_HIT_LAYER_ID, clearTrackCursor)
}
if (!map.getSource(PLACE_CLUSTER_SOURCE_ID)) {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: 30,
clusterMaxZoom: 10,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
addPlaceClusterLayers(map)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const zoomToCluster = (e: any) => {
const features = typeof map.queryRenderedFeatures === 'function'
@@ -845,6 +1331,15 @@ export function MapViewGL({
&& typeof map.queryRenderedFeatures === 'function'
&& map.queryRenderedFeatures(e.point, { layers: [GPX_HIT_LAYER_ID] }).length > 0
) return
// And for the drive itself while it can be reshaped: that click just put a via
// there, and it must not also drop a place on top of it (#1797).
if (
onRouteClickRef.current
&& typeof map.getLayer === 'function'
&& map.getLayer('trip-route-hit')
&& typeof map.queryRenderedFeatures === 'function'
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
})
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
@@ -1168,12 +1663,7 @@ export function MapViewGL({
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
// but it rotates the element by the pitch angle and visually offsets
// the anchor by ~100px at 45° tilt, which caused the observed drift.
const pin = isMapLibre && pinLayerRef.current
? makePlacePin(map, pinLayerRef.current, el, place.lng, place.lat)
: libraryPin(new gl.Marker({ element: el, anchor: 'center' })
.setLngLat([place.lng, place.lat])
.addTo(map))
markersRef.current.set(place.id, pin)
markersRef.current.set(place.id, attachPin(map, gl, pinLayerRef.current, el, place.lng, place.lat))
})
}
@@ -1185,6 +1675,18 @@ export function MapViewGL({
return
}
// Road trip mode does not cluster at all: the whole drive has to stay readable
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
// is being looked at for. The source is emptied rather than reconfigured — the
// cluster options can only be set when a GeoJSON source is created, and dropping and
// re-adding a source under three layers to change a radius is not worth it when the
// markers are drawn from `places` anyway.
if (clusterLoosely) {
source.setData({ type: 'FeatureCollection', features: [] })
reconcileMarkers(validPlaces)
return
}
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
@@ -1223,7 +1725,7 @@ export function MapViewGL({
map.off('zoomend', scheduleReconcile)
map.off('idle', scheduleReconcile)
}
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them).
@@ -1233,15 +1735,19 @@ export function MapViewGL({
popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category)
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m)
// Dragging a hit onto the drive puts it where it is passed, rather than where the
// corridor happened to project it. Only wired when someone is listening for it.
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
}
}, [pois, mapReady, glProvider])
@@ -1287,8 +1793,7 @@ export function MapViewGL({
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
})
}
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([v.lng, v.lat]).addTo(map)
routeViaMarkersRef.current.push(m)
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
}
}, [routeVias, mapReady, glProvider])
@@ -1306,7 +1811,7 @@ export function MapViewGL({
popupRef.current?.setLngLat([mk.lng, mk.lat]).setDOMContent(buildPluginMarkerPopup(mk)).addTo(map)
})
}
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([mk.lng, mk.lat]).addTo(map)
const m = attachPin(map, gl, pinLayerRef.current, el, mk.lng, mk.lat)
pluginMarkersRef.current.push(m)
}
}, [pluginMarkers, mapReady, glProvider])
@@ -1325,6 +1830,22 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features })
}, [route, mapReady])
// Update access-spur geojson
useEffect(() => {
const map = mapRef.current
if (!map) return
const src = map.getSource('trip-access') as mapboxgl.GeoJSONSource | undefined
if (!src) return
src.setData({
type: 'FeatureCollection',
features: (accessLines || []).map(spur => ({
type: 'Feature' as const,
properties: { meters: Math.round(spur.meters) },
geometry: { type: 'LineString' as const, coordinates: spur.line.map(([lat, lng]) => [lng, lat]) },
})),
})
}, [accessLines, mapReady])
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
// Update GPX geometries
@@ -1452,6 +1973,25 @@ export function MapViewGL({
if (routeArrivedForPendingFit) pendingRouteFitRef.current = null
}, [fitKey, routeFitKey]) // eslint-disable-line react-hooks/exhaustive-deps
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
const map = mapRef.current
if (!map || !focusPoints?.length) return
const bounds = new gl.LngLatBounds()
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
// A day fit still waiting on its route must not overwrite this a moment later.
pendingRouteFitRef.current = null
try {
map.fitBounds(bounds, {
padding: paddingOpts,
maxZoom: 15,
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
})
} catch { /* noop */ }
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place
useEffect(() => {
const map = mapRef.current
@@ -0,0 +1,85 @@
import React, { useMemo, useRef } from 'react'
import { Marker, Tooltip } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { useStableVias } from './viaMarkerState'
import type { RoadtripVia } from '@trek/shared'
/**
* The handles that shape a drive.
*
* A via is deliberately small and unnumbered: it is not a stop, and anything that looks
* like one would read as a place on the itinerary. Dragging it redraws the route through
* the new spot, which is the whole interaction — the same one a paper map gets when you
* put a finger on the road you actually want to take.
*/
const VIA_ICON = L.divIcon({
className: '',
html: `<span style="
display:block;width:12px;height:12px;border-radius:9999px;
background:#0a84ff;border:2.5px solid #ffffff;
box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;
"></span>`,
iconSize: [12, 12],
iconAnchor: [6, 6],
})
interface RoadtripViaMarkersProps {
viasByDay: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
onRemoveVia?: (dayId: number, id: number) => void
}
/** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */
export default function RoadtripViaMarkers({
viasByDay, onMoveVia, onRemoveVia,
}: RoadtripViaMarkersProps): React.ReactElement | null {
const { t } = useTranslation()
const vias = useStableVias(viasByDay)
// Read through a ref so the handlers below can stay the same objects across renders.
// Both callbacks are rebuilt on every render of the planner, and react-leaflet compares
// its props by identity: a fresh `eventHandlers` unbinds and rebinds every listener,
// and a fresh `position` array calls `setLatLng` on every marker. Doing either while a
// drag is in flight is how a freshly placed via lost its first drag.
const handlersRef = useRef({ onMoveVia, onRemoveVia })
handlersRef.current = { onMoveVia, onRemoveVia }
const draggable = !!onMoveVia
const markers = useMemo(() => vias.map(via => ({
via,
position: [via.lat, via.lng] as [number, number],
handlers: {
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
const at = e.target.getLatLng()
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
},
// Right-click rather than a delete handle: a 12px dot has no room for one,
// and the same gesture removes things elsewhere on the map.
contextmenu: (e: { originalEvent: MouseEvent }) => {
e.originalEvent.preventDefault()
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
},
},
})), [vias])
if (!markers.length) return null
return (
<>
{markers.map(({ via, position, handlers }) => (
<Marker
key={`via-${via.id}`}
position={position}
icon={VIA_ICON}
draggable={draggable}
zIndexOffset={400}
eventHandlers={handlers}
>
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
{t('roadtrip.via.hint')}
</Tooltip>
</Marker>
))}
</>
)
}
+262 -13
View File
@@ -12,10 +12,11 @@ import {
generateCoMapsUrl,
parsePluginProfile,
withHotelBookends,
calculateAlternatives,
} from './RouteCalculator'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
// calculateRouteWithLegs talks to the FOSSGIS per-profile hosts, not the car-only demo.
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
// demo ignored the profile in the URL, so walking routes followed the road network.
const FOSSGIS = {
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
@@ -46,7 +47,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse())
)
)
@@ -56,7 +57,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(1500, 360))
)
)
@@ -66,7 +67,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(800, 360))
)
)
@@ -78,7 +79,7 @@ describe('calculateRoute', () => {
const distance = 5000
const osrmDuration = 999
server.use(
http.get(`${OSRM_BASE}/walking/:coords`, () =>
http.get(`${FOSSGIS.walking}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration))
)
)
@@ -90,7 +91,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json({}, { status: 500 })
)
)
@@ -99,7 +100,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json({ code: 'NoRoute', routes: [] })
)
)
@@ -108,7 +109,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse())
)
)
@@ -128,7 +129,7 @@ describe('calculateSegments', () => {
it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json({
code: 'Ok',
routes: [
@@ -325,7 +326,7 @@ describe('parsePluginProfile', () => {
describe('calculateRoute profiles', () => {
it('FE-COMP-ROUTECALCULATOR-030: cycling overrides the OSRM duration with a 15 km/h estimate', async () => {
server.use(
http.get(`${OSRM_BASE}/cycling/:coords`, () =>
http.get(`${FOSSGIS.cycling}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(9000, 4242))
)
)
@@ -340,12 +341,12 @@ describe('calculateRoute profiles', () => {
describe('calculateSegments failures', () => {
it('FE-COMP-ROUTECALCULATOR-031: throws when OSRM answers with an HTTP error', async () => {
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({}, { status: 502 })))
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 502 })))
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('Route could not be calculated')
})
it('FE-COMP-ROUTECALCULATOR-032: throws when OSRM reports no usable route', async () => {
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('No route found')
})
})
@@ -613,3 +614,251 @@ describe('generateCoMapsUrl', () => {
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
})
})
describe('a self-hosted routing engine', () => {
afterEach(() => {
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
})
const useOwnRouter = (base: string) =>
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: base } }))
it('FE-COMP-ROUTECALCULATOR-021: a configured instance is asked instead of the public hosts', async () => {
useOwnRouter('https://osrm.example.org')
let asked = ''
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
expect(asked).toContain('osrm.example.org')
})
it('FE-COMP-ROUTECALCULATOR-022: OSRM’s own profile names are used, not TREK’s', async () => {
useOwnRouter('https://osrm.example.org')
let asked = ''
// TREK says "walking" and "cycling"; osrm-routed serves "foot" and "bike".
server.use(http.get('https://osrm.example.org/route/v1/foot/:coords', ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'walking')
expect(asked).toContain('/route/v1/foot/')
})
it('FE-COMP-ROUTECALCULATOR-023: a trailing slash in the setting does not double up', async () => {
useOwnRouter('https://osrm.example.org///')
let asked = ''
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
expect(asked).not.toContain('//route')
})
it('FE-COMP-ROUTECALCULATOR-024: blank or whitespace falls back to the public hosts', async () => {
useOwnRouter(' ')
let asked = ''
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
expect(asked).toContain('routing.openstreetmap.de')
})
})
describe('calculateAlternatives', () => {
const twoRoutes = {
code: 'Ok',
routes: [
// The direct one, straight down the middle.
{ geometry: { coordinates: [[10, 53], [10.5, 52.5], [11, 52]] }, distance: 100000, duration: 3600 },
// A detour that swings well to the east before rejoining.
{ geometry: { coordinates: [[10, 53], [12.5, 52.5], [11, 52]] }, distance: 130000, duration: 4500 },
],
}
it('FE-COMP-ROUTECALCULATOR-025: asks for alternatives between exactly two points', async () => {
let asked = ''
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
asked = request.url
return HttpResponse.json(twoRoutes)
}))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(asked).toContain('alternatives=3')
expect(routes).toHaveLength(2)
expect(routes[0].distance).toBe(100000)
})
it('FE-COMP-ROUTECALCULATOR-026: each alternative carries the point that makes it different', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(twoRoutes)))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
// The first is what the router gives anyway, so it needs no pinning point.
expect(routes[0].divergence).toBeNull()
// The second diverges at its eastern swing — saving that as a via forces this road.
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
})
/** The router's own answer, and whatever it says when a road class is left out. */
const withExclusions = (byExclude: Record<string, unknown>) =>
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
const answer = byExclude[exclude]
return answer ? HttpResponse.json(answer) : HttpResponse.json({ code: 'NoRoute', routes: [] })
})
it('FE-COMP-ROUTECALCULATOR-027: a road that is the same road either way is not offered twice', async () => {
// Leaving the motorway out changed nothing — the leg does not touch one. Offering
// the identical line a second time would be a choice that is not a choice.
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[0]] }, toll: { code: 'Ok', routes: [twoRoutes.routes[0]] } }))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(routes).toHaveLength(1)
expect(routes[0].divergence).toBeNull()
})
it('FE-COMP-ROUTECALCULATOR-029: one answer from the router still offers the motorway-free way', async () => {
// OSRM answers most long legs with exactly one route, which used to mean the picker
// said there was no other way — while a perfectly good slower road existed.
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[1]] } }))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(routes).toHaveLength(2)
expect(routes[1].avoids).toBe('motorway')
// Pinnable like any other offer: the point where it leaves the direct line.
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
})
it('FE-COMP-ROUTECALCULATOR-030: tolls are only asked about when the motorway gave nothing', async () => {
const asked: string[] = []
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (exclude) asked.push(exclude)
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
if (exclude === 'motorway') return HttpResponse.json({ code: 'NoRoute', routes: [] })
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
}))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(asked).toEqual(['motorway', 'toll'])
expect(routes[1].avoids).toBe('toll')
})
it('FE-COMP-ROUTECALCULATOR-031: walking is never asked to leave out a road class', async () => {
// The foot profile has no excludable classes; asking earns an InvalidOptions for
// nothing, and a walk has no motorway to avoid in the first place.
const asked: string[] = []
server.use(http.get(`${FOSSGIS.walking}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (exclude) asked.push(exclude)
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
}))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }, 'walking')
expect(asked).toEqual([])
expect(routes).toHaveLength(1)
})
it('FE-COMP-ROUTECALCULATOR-032: a host that rejects exclude is only asked once', async () => {
// Both public hosts TREK ships with answer 400 here, so without this every long leg
// pays two extra requests against a one-per-second limit to be refused twice.
const asked: string[] = []
server.use(http.get('*/route/v1/driving/*', ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
asked.push(exclude)
return new HttpResponse(null, { status: 400 })
}))
await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 13 }, 'driving')
const afterFirst = asked.length
await calculateAlternatives({ lat: 51, lng: 9 }, { lat: 50, lng: 12 }, 'driving')
expect(afterFirst).toBe(1)
expect(asked).toHaveLength(1)
})
it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => {
// OSRM reports these in every answer and TREK threw them away since the first route
// was drawn. Without them a place set back from the road looks like it is on the
// route, while the drive really starts somewhere else.
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
code: 'Ok',
waypoints: [
{ location: [10.02, 53.51], distance: 812.5 },
{ location: [13.0, 52.0], distance: 4 },
],
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
})))
const r = await calculateRouteWithLegs([{ lat: 53.5, lng: 10.0 }, { lat: 52, lng: 13 }])
expect(r.snapped).toEqual([
{ asked: [53.5, 10.0], at: [53.51, 10.02], meters: 812.5 },
{ asked: [52, 13], at: [52.0, 13.0], meters: 4 },
])
})
it('FE-COMP-ROUTECALCULATOR-034: a distance the router left out is worked out from the two points', async () => {
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
code: 'Ok',
waypoints: [{ location: [10.0, 53.0] }, { location: [13.0, 52.0] }],
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
})))
const r = await calculateRouteWithLegs([{ lat: 53.01, lng: 10.0 }, { lat: 52, lng: 13 }])
// Roughly 1.1 km for a hundredth of a degree of latitude; the point is that it is a
// real number rather than the whole answer being discarded.
expect(r.snapped?.[0].meters).toBeGreaterThan(1000)
expect(r.snapped?.[0].meters).toBeLessThan(1200)
})
it('FE-COMP-ROUTECALCULATOR-035: an answer that does not describe its waypoints changes nothing', async () => {
// All-or-nothing: a partial list would have to be indexed by waypoint anyway, and one
// bad entry would hang the spur off the wrong stop.
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
code: 'Ok',
waypoints: [{ location: [10.0, 53.0] }],
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
})))
const r = await calculateRouteWithLegs([{ lat: 53, lng: 10 }, { lat: 52, lng: 13 }])
expect(r.snapped).toBeUndefined()
expect(r.coordinates).toHaveLength(2)
})
it('FE-COMP-ROUTECALCULATOR-028: no route at all is an empty list, not a throw', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json({ code: 'NoRoute', routes: [] })))
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })).resolves.toEqual([])
})
it('FE-COMP-ROUTECALCULATOR-029: a refused request is a RoutingRefusedError, not a generic one', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 429 })))
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }))
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
})
})
+286 -11
View File
@@ -1,10 +1,9 @@
import { useSettingsStore } from '../../store/settingsStore'
import { pluginsApi } from '../../api/client'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
// the matching profile so walking routes follow footpaths, not the road network.
@@ -14,6 +13,74 @@ const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
}
/** OSRM's own profile names, which a self-hosted instance serves under its own paths. */
const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = {
driving: 'driving',
walking: 'foot',
cycling: 'bike',
}
/**
* Where to ask for a route.
*
* The public FOSSGIS hosts allow roughly one request a second, which a road trip — every
* leg of every day — runs into immediately. An instance that runs its own OSRM sets
* `routing_base_url` and everything routes against that instead; the server has to name
* the same origin in its connect-src, or the browser blocks the requests silently.
*
* A configured base is expected to serve the standard OSRM layout,
* `<base>/route/v1/<profile>/…`, which is what `osrm-routed` does out of the box.
*/
/**
* A configured base URL without its trailing slashes.
*
* Walked rather than matched with `/\/+$/`: that pattern backtracks over a run of
* slashes, and the value comes from an instance setting somebody types. Linear either
* way in practice, but the regex is the shape a scanner is right to flag.
*/
function withoutTrailingSlashes(value: string): string {
let end = value.length
while (end > 0 && value[end - 1] === '/') end -= 1
return value.slice(0, end)
}
function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
const configured = useSettingsStore.getState().settings.routing_base_url?.trim()
if (!configured) return OSRM_PROFILE_BASE[profile]
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
}
/**
* A routing request the host refused, as opposed to one it answered with "no route".
*
* The public OSRM instances allow roughly one request a second and answer 429 above that.
* Both used to arrive as the same generic Error, so a caller could not tell "back off and
* try again" from "these two points are not connected by road" — and a road trip, which
* asks for every leg of every day, hits the first case constantly while the second is
* rare. `retryAfterMs` carries the host's own `Retry-After` when it sends one.
*/
export class RoutingRefusedError extends Error {
constructor(readonly status: number, readonly retryAfterMs: number | null) {
super(status === 429 ? 'Routing rate limit reached' : 'Route could not be calculated')
this.name = 'RoutingRefusedError'
}
/** True when waiting and asking again is the right response. */
get isRateLimit(): boolean {
return this.status === 429 || this.status === 503
}
}
/** `Retry-After` is either seconds or an HTTP date; anything else is no answer at all. */
function retryAfterMs(response: Response): number | null {
const raw = response.headers.get('Retry-After')
if (!raw) return null
const seconds = Number(raw)
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)
const at = Date.parse(raw)
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
}
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
const routeCache = new Map<string, RouteWithLegs>()
@@ -45,11 +112,11 @@ export async function calculateRoute(
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error('Route could not be calculated')
throw new RoutingRefusedError(response.status, retryAfterMs(response))
}
const data = await response.json()
@@ -252,10 +319,10 @@ export async function calculateSegments(
if (!waypoints || waypoints.length < 2) return []
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Route could not be calculated')
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
@@ -344,10 +411,13 @@ export async function calculateRouteWithLegs(
return result
}
const osrmProfile = (profile === 'walking' || profile === 'cycling') ? profile : 'driving'
const url = `${OSRM_PROFILE_BASE[osrmProfile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
// Written as literals rather than narrowing `profile`: its type is an open string union
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
const osrmProfile: 'driving' | 'walking' | 'cycling' =
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Route could not be calculated')
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
@@ -374,7 +444,8 @@ export async function calculateRouteWithLegs(
}
)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
const snapped = readSnapped(data, waypoints)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
routeCache.set(cacheKey, result)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
@@ -383,6 +454,37 @@ export async function calculateRouteWithLegs(
return result
}
/**
* The snap positions out of an OSRM answer, or undefined if it did not describe them.
*
* OSRM reports this in every response and TREK has thrown it away since the first route
* was drawn. `distance` is the straight line from the coordinate we sent to the road it
* used; it is recomputed from the two points when the field is missing or nonsense, so a
* mirror that trims its answers still produces a usable gap rather than a wrong one.
*
* All-or-nothing on purpose: a partial list would have to be indexed by waypoint anyway,
* and one bad entry would put a spur on the wrong stop.
*/
function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] | undefined {
const raw = (data as { waypoints?: unknown })?.waypoints
if (!Array.isArray(raw) || raw.length !== waypoints.length) return undefined
const out: SnappedWaypoint[] = []
for (let i = 0; i < raw.length; i++) {
const loc = (raw[i] as { location?: unknown })?.location
if (!Array.isArray(loc) || loc.length < 2) return undefined
const [lng, lat] = loc as [number, number]
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
const asked: [number, number] = [waypoints[i].lat, waypoints[i].lng]
const at: [number, number] = [lat, lng]
const reported = (raw[i] as { distance?: unknown })?.distance
const meters = typeof reported === 'number' && Number.isFinite(reported) && reported >= 0
? reported
: haversineKm({ lat: asked[0], lng: asked[1] }, { lat, lng }) * 1000
out.push({ asked, at, meters })
}
return out
}
function getDistanceUnit(): DistanceUnit {
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
}
@@ -403,3 +505,176 @@ function formatDuration(seconds: number): string {
}
return `${m} min`
}
/** One way of driving a leg, as OSRM offers it. */
export interface RouteAlternative {
coordinates: [number, number][]
distance: number
duration: number
/** Where this route differs most from the first one — the point that would pin it. */
divergence: { lat: number; lng: number } | null
/** Set when this way exists because a road class was left out of it. */
avoids?: 'motorway' | 'toll'
}
/**
* Road classes worth asking the router to leave out, in the order they are tried.
*
* OSRM's own alternative search is conservative: it only offers a second road when the
* detour is short enough and shares little enough with the first, so on a long motorway
* run it usually answers with exactly one route — while a perfectly good, slower way over
* the B-roads exists and is what somebody opening "other ways" is looking for. Excluding
* a class asks a different question and reliably produces that road.
*
* Only for driving: the foot and bike profiles have no excludable classes, and asking
* anyway earns an `InvalidOptions` for nothing.
*/
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
/** Two lines are the same road if their length and their duration agree closely enough. */
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
}
/**
* Hosts that have already answered "I do not do exclude", by base URL.
*
* Both public hosts TREK ships with reject the parameter outright with HTTP 400: the
* upstream car profile is built without excludable classes, and only a self-hosted OSRM
* built through the MLD pipeline supports it. Without this, every long leg on a default
* install pays two extra requests to be told twice that it cannot have what it asked for,
* against a host that allows one request per second.
*
* The first real attempt is the probe; there is no separate one. Only a 400 is recorded,
* because only a 400 is about the host rather than about this particular question: a 429
* is a rate limit, an abort is the user moving on, and a `NoRoute` means this leg has no
* way round the motorway while the next one might.
*
* Per base URL rather than global, so pointing the instance at an own router that does
* support it starts asking again without a reload.
*/
const excludeUnsupported = new Set<string>()
/**
* One route with a road class left out, or null when the router will not or cannot.
*
* Every failure is a null: a rate limit answers 429, and a leg with no way round the
* motorway answers `NoRoute`. Neither is worth an error on screen — they just mean this
* particular question had no answer.
*/
async function routeExcluding(
coords: string,
profile: 'driving' | 'walking' | 'cycling',
exclude: string,
signal?: AbortSignal,
): Promise<RouteAlternative | null> {
const base = routeBaseFor(profile)
if (excludeUnsupported.has(base)) return null
try {
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
const response = await fetch(url, { signal })
if (!response.ok) {
// 400 is the router saying the parameter itself is not available here, which is
// true of every leg from now on. Anything else is about this request alone.
if (response.status === 400) excludeUnsupported.add(base)
return null
}
const data = await response.json()
const route = data?.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null
if (!route?.geometry?.coordinates?.length) return null
return {
coordinates: route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]),
distance: route.distance,
duration: route.duration,
divergence: null,
}
} catch {
return null
}
}
/**
* The ways of driving one leg, not one day.
*
* Deliberately per leg: OSRM only offers alternatives between exactly two coordinates, so
* asking for a whole day would mean splitting it into one request per leg — which is the
* bundling `splitIntoRuns` exists to avoid, and the reason a road trip appears at once
* instead of trickling in. Asked for a single leg, on demand, it costs one extra request.
*
* `divergence` is the point on each alternative that lies furthest from the default route.
* It is what makes a choice persistable: saving that point as a via forces the router back
* onto this road on every future request, without storing a polyline that would go stale
* with the next OSM update.
*/
export async function calculateAlternatives(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling' = 'driving',
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
): Promise<RouteAlternative[]> {
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
const response = await fetch(url, { signal })
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
if (data.code !== 'Ok' || !Array.isArray(data.routes) || !data.routes.length) return []
const routes: RouteAlternative[] = data.routes.map((r: { geometry: { coordinates: [number, number][] }; distance: number; duration: number }) => ({
coordinates: r.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]),
distance: r.distance,
duration: r.duration,
divergence: null,
}))
// Nothing but the router's own preference came back, which is what it answers on most
// long legs. Rather than reporting "only one sensible way", ask a different question:
// the same drive without the motorway, and failing that without the tolls. Tried one at
// a time and stopped as soon as one lands, so a leg that does have a second road costs
// one extra request rather than three.
//
// Worth knowing before reading further: on the two public hosts TREK ships with, this
// never produces anything. Their car profile is built without excludable classes and
// the parameter comes back as HTTP 400, which is why long legs keep reporting a single
// way to drive them. It works against a self-hosted OSRM built through the MLD
// pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once.
if (routes.length < 2 && profile === 'driving') {
for (const exclude of EXCLUDABLE_CLASSES) {
if (signal?.aborted) break
const detour = await routeExcluding(coords, profile, exclude, signal)
if (detour && !routes.some(r => sameRoad(r, detour))) {
detour.avoids = exclude
routes.push(detour)
break
}
}
}
// The first route is what the router would have given anyway; the others are measured
// against it so each one can be pinned by the point that makes it different.
const [primary, ...rest] = routes
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
return routes
}
/** The point of `line` that lies furthest from `reference`, in plain degrees. */
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null {
if (!line.length || !reference.length) return null
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
// full cross product of two thousand-point lines is not worth the milliseconds.
const step = Math.max(1, Math.floor(reference.length / 200))
let best: { lat: number; lng: number } | null = null
let bestDist = -1
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
const [lat, lng] = line[i]
let nearest = Infinity
for (let j = 0; j < reference.length; j += step) {
const dLat = lat - reference[j][0]
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
const d = dLat * dLat + dLng * dLng
if (d < nearest) nearest = d
}
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
}
return best
}
@@ -1,5 +1,7 @@
import mapboxgl from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
import { registerRtlTextPlugin } from '../rtlText'
/**
* The only module in the client that pulls mapbox-gl in at runtime.
@@ -8,4 +10,8 @@ import 'mapbox-gl/dist/mapbox-gl.css'
* a static import from anywhere in the tree drags 1.8 MB into the parent chunk and
* quietly undoes the split. `npm run check:gl-split` fails on that.
*/
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
// Mapbox's signature is (url, callback, deferred) — see rtlText.ts.
registerRtlTextPlugin(() => mapboxgl.setRTLTextPlugin(rtlTextPluginUrl, null, true))
export default mapboxgl
@@ -1,5 +1,7 @@
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
import { registerRtlTextPlugin } from '../rtlText'
/**
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
@@ -10,4 +12,8 @@ import 'maplibre-gl/dist/maplibre-gl.css'
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
* on the provider (projection, accessToken, aroundCenter).
*/
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
export default maplibregl
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The two lines this fix actually adds. Everything else is a helper: the guard in
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
* working fix from the outside. Deleting either registration, swapping an
* argument or dropping the deferred flag left the whole suite green.
*
* The engines are mocked because importing them for real builds a worker pool
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
*/
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
mapboxSet: vi.fn(),
maplibreSet: vi.fn(),
}));
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
beforeEach(() => {
vi.resetModules();
mapboxSet.mockReset();
maplibreSet.mockReset();
});
describe('the GL engines register the RTL text plugin (#2235)', () => {
it('FE-MAP-RTL-001: importing the mapbox engine registers it deferred, with a null callback', async () => {
await import('./mapbox');
expect(mapboxSet).toHaveBeenCalledTimes(1);
// Mapbox GL 3 takes (url, callback, deferred). The third argument is what
// keeps the payload off the wire until a map paints RTL text; dropping it
// would fetch 133 KB for every user on every map.
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
});
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
await import('./maplibre');
expect(maplibreSet).toHaveBeenCalledTimes(1);
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
// the call stays with each engine instead of going through one wrapper.
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
});
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
mapboxSet.mockImplementation(() => { throw new Error('already registered'); });
const engine = await import('./mapbox');
expect(engine.default).toBeDefined();
});
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
const engine = await import('./maplibre');
expect(engine.default).toBeDefined();
// Give the rejection a tick to go unhandled if nothing caught it.
await new Promise((resolve) => setTimeout(resolve, 0));
});
});
+91 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { makeMarkerDraggable } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
/**
* jsdom has no drag implementation, so these drive the events the browser would
@@ -82,3 +82,93 @@ describe('makeMarkerDraggable', () => {
expect(window.__dragData).toBeNull()
})
})
describe('makePoiDraggable', () => {
afterEach(() => {
window.__dragData = null
document.body.classList.remove('dragging-poi')
})
const dragEvent = (type: string) => {
const e = new Event(type, { bubbles: true, cancelable: true }) as DragEvent & { dataTransfer: DataTransfer }
const store: Record<string, string> = {}
Object.defineProperty(e, 'dataTransfer', {
value: {
setData: (k: string, v: string) => { store[k] = v },
getData: (k: string) => store[k] ?? '',
effectAllowed: '',
dropEffect: '',
},
})
return e
}
it('FE-MAP-POIDRAG-001: a hit carries its OSM id, not a place id — it is not a place yet', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
expect(el.getAttribute('draggable')).toBe('true')
const e = dragEvent('dragstart')
el.dispatchEvent(e)
expect(e.dataTransfer.getData('poiOsmId')).toBe('node:9')
// Mirrored on window because dataTransfer is unreadable during dragover, and the
// map has to know what is coming while it is still being hovered.
expect(window.__dragData).toEqual({ poiOsmId: 'node:9' })
})
it('FE-MAP-POIDRAG-002: the drag marks the body, so the route can light up while it lasts', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
el.dispatchEvent(dragEvent('dragstart'))
expect(document.body.classList.contains('dragging-poi')).toBe(true)
el.dispatchEvent(dragEvent('dragend'))
expect(document.body.classList.contains('dragging-poi')).toBe(false)
expect(window.__dragData).toBeNull()
})
it('FE-MAP-POIDRAG-003: mousedown is swallowed, or the map pans out from under the drag', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
const seen = vi.fn()
document.body.appendChild(el)
document.body.addEventListener('mousedown', seen)
el.dispatchEvent(down)
expect(seen).not.toHaveBeenCalled()
document.body.removeEventListener('mousedown', seen)
el.remove()
})
it('FE-MAP-POIDRAG-004: unwiring leaves the element as it was found', () => {
const el = document.createElement('div')
const off = makePoiDraggable(el, 'node:9')
off()
expect(el.hasAttribute('draggable')).toBe(false)
el.dispatchEvent(dragEvent('dragstart'))
expect(window.__dragData).toBeNull()
})
it('FE-MAP-POIDRAG-005: the id is readable from the event, and from the mirror during a hover', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
const start = dragEvent('dragstart')
el.dispatchEvent(start)
expect(draggedPoiId(start)).toBe('node:9')
// A dragover event carries an unreadable dataTransfer; the mirror answers instead.
const over = new Event('dragover') as DragEvent
expect(draggedPoiId(over)).toBe('node:9')
})
it('FE-MAP-POIDRAG-006: an unrelated drag is not mistaken for a hit', () => {
const over = new Event('dragover') as DragEvent
expect(draggedPoiId(over)).toBeNull()
})
})
+48
View File
@@ -48,3 +48,51 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
el.removeEventListener('mousedown', onMouseDown)
}
}
/**
* Wire a corridor hit up as a drag source, so it can be dropped onto the drive.
*
* The same HTML5 drag as `makeMarkerDraggable`, with the OSM id as payload instead of a
* place id — a corridor hit is not a place yet. The drop target is the map itself rather
* than the drawn line: a route polyline is a real element only in Leaflet (the GL
* renderers draw it into the canvas), and the drop coordinate answers "where on the drive"
* just as well once it is projected onto the routed geometry.
*/
export function makePoiDraggable(el: HTMLElement, osmId: string): () => void {
el.setAttribute('draggable', 'true')
const onDragStart = (e: DragEvent) => {
e.dataTransfer?.setData('poiOsmId', osmId)
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { poiOsmId: osmId }
el.classList.add('marker-dragging')
document.body.classList.add('dragging-poi')
}
const onDragEnd = () => {
window.__dragData = null
el.classList.remove('marker-dragging')
document.body.classList.remove('dragging-poi')
}
// Same reason as above: without this the map pans out from under the pointer as the
// drag begins, and the route is impossible to aim at.
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
el.addEventListener('dragstart', onDragStart)
el.addEventListener('dragend', onDragEnd)
el.addEventListener('mousedown', onMouseDown)
return () => {
el.removeAttribute('draggable')
el.removeEventListener('dragstart', onDragStart)
el.removeEventListener('dragend', onDragEnd)
el.removeEventListener('mousedown', onMouseDown)
}
}
/** The OSM id being dragged, when a corridor hit is in flight. */
export function draggedPoiId(e: DragEvent): string | null {
const fromTransfer = e.dataTransfer?.getData('poiOsmId')
if (fromTransfer) return fromTransfer
// dataTransfer is unreadable during dragover, so the window mirror answers instead.
return window.__dragData?.poiOsmId ?? null
}
+34 -3
View File
@@ -1,4 +1,8 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
// The road-trip kinds take their colour from the one place that defines it, so the
// search result, the map pin and the road-trip rail cannot drift apart.
import { SERVICE_COLORS } from '../Roadtrip/roadtripModel'
import { CORRIDOR_CATEGORIES, HOTEL_COLOR } from '../Roadtrip/stopKinds'
// The POI categories shown in the map "explore" pill. The `key` is the contract
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
@@ -15,15 +19,27 @@ export const POI_CATEGORIES: PoiCategory[] = [
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
// The colour lives with the road-trip categories, because the corridor search offers
// the same one and two definitions of a blue is how they drift apart.
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR },
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
]
/**
* What a drive needs. Kept out of POI_CATEGORIES on purpose: these are not offered by
* the explore pill, they are what the road trip corridor searches for. They are in the
* lookup below all the same, so a hit drawn on the map gets its own icon and colour
* instead of the nameless grey dot an unknown category falls back to.
*/
export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
)
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
POI_CATEGORIES.map(c => [c.key, c]),
[...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c]),
)
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
@@ -34,10 +50,25 @@ export interface Poi {
lng: number
category: string
poi_type: string
/** Brand name and its Wikidata id, when OSM carries them (road categories mostly do). */
brand?: string | null
brand_wikidata?: string | null
address: string | null
website: string | null
phone: string | null
opening_hours: string | null
cuisine: string | null
/**
* What a charging station offers, when it is one and OSM says so.
*
* Optional twice over: only charging hits carry it at all, and OSM knows a socket type
* for roughly a third of them, a capacity for about seven in ten. "Not stated" is its
* own answer here and must not be read as a no.
*/
charging?: {
sockets: { type: string; count: number | null; kw: number | null }[]
capacity: number | null
fee: boolean | null
} | null
source: 'openstreetmap'
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest'
import { registerRtlTextPlugin } from './rtlText'
describe('registerRtlTextPlugin', () => {
it('runs the engine-supplied registration', () => {
const register = vi.fn()
expect(registerRtlTextPlugin(register)).toBe(true)
expect(register).toHaveBeenCalledTimes(1)
})
// Registering twice is how the engines report it: Mapbox throws.
it('never lets a refused registration break map creation', () => {
const register = vi.fn(() => {
throw new Error('setRTLTextPlugin cannot be called multiple times.')
})
expect(() => registerRtlTextPlugin(register)).not.toThrow()
expect(registerRtlTextPlugin(register)).toBe(false)
})
// MapLibre reports the same by rejecting, and an unhandled rejection would
// reach the console for something the user cannot act on.
it('swallows a rejected registration promise', async () => {
const onUnhandled = vi.fn()
process.on('unhandledRejection', onUnhandled)
expect(registerRtlTextPlugin(() => Promise.reject(new Error('404')))).toBe(true)
await new Promise(resolve => setTimeout(resolve, 0))
process.off('unhandledRejection', onUnhandled)
expect(onUnhandled).not.toHaveBeenCalled()
})
it('leaves a resolved promise alone', async () => {
expect(registerRtlTextPlugin(() => Promise.resolve())).toBe(true)
})
})
+48
View File
@@ -0,0 +1,48 @@
/**
* Arabic, Hebrew, Persian and Urdu labels need two things a GL engine does not do
* on its own: the bidirectional algorithm, and Arabic contextual shaping (a letter
* is drawn differently depending on its neighbours). Without them the engine lays
* the codepoints out one by one, left to right, in isolated forms — so القاهرة
* comes out as ة ر ه ا ق ل ا, unjoined and reversed. Raster basemaps never showed
* this because their labels are drawn server-side, which is why it only surfaced
* once the default basemap became a vector style.
*
* Both engines expose the fix as an optional plugin and neither loads it by
* default. Registering is global to the engine, so it happens once per engine
* module rather than at each map's construction site.
*
* The call itself stays with the caller: this is one of the places the two engines
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
* module passes its own invocation rather than a shared wrapper guessing an arity.
*
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
* build its worker pool, which is an eager side effect for a module that is
* imported for its default export, and throws outright under jsdom. Registering
* twice is reported — by a throw or a rejected promise depending on the engine —
* and both are handled below, so the probe bought nothing.
*/
/**
* Runs a GL engine's RTL-plugin registration, swallowing the ways it can fail.
*
* Callers register lazily, so the payload is only fetched once a map actually
* paints RTL text and a user who never leaves Latin script downloads nothing.
*
* @returns whether the registration call completed without throwing.
*/
export function registerRtlTextPlugin(register: () => void | Promise<unknown>): boolean {
try {
const pending = register()
// MapLibre reports a failed or duplicate registration by rejecting; Mapbox
// routes the same through its callback. Neither is worth surfacing: a map
// with unshaped labels still renders, and an unhandled rejection would not
// give the user anything to act on.
if (pending instanceof Promise) pending.catch(() => {})
return true
} catch {
// Already registered, or the engine refused outright. Registering the plugin
// must never be able to break map creation.
return false
}
}
@@ -0,0 +1,68 @@
import { renderHook } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useStableVias } from './viaMarkerState'
import type { RoadtripVia } from '@trek/shared'
/**
* The list both map renderers rebuild their via handles from.
*
* Its identity is the whole contract: the GL renderer replaces the DOM element when this
* changes, and a pointerdown belongs to the element it happened on. A list that changed
* on every render meant a freshly placed via lost its first drag to the re-route landing
* underneath it.
*/
const via = (over: Partial<RoadtripVia> & { id: number }): RoadtripVia => ({
day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1, ...over,
})
describe('useStableVias', () => {
it('FE-VIASTATE-001: the same vias in a fresh object are the same list', () => {
// Exactly what a reload hands down: identical rows, a new object every time.
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
)
const first = result.current
rerender({ byDay: { 1: [via({ id: 5 })] } })
expect(result.current).toBe(first)
})
it('FE-VIASTATE-002: a via that moved is a new list, so the handle is redrawn where it now is', () => {
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
)
const first = result.current
rerender({ byDay: { 1: [via({ id: 5, lat: 48.9 })] } })
expect(result.current).not.toBe(first)
expect(result.current[0].lat).toBe(48.9)
})
it('FE-VIASTATE-003: adding or removing one is a new list too', () => {
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
)
const first = result.current
rerender({ byDay: { 1: [via({ id: 5 }), via({ id: 6, lat: 48.4 })] } })
expect(result.current).not.toBe(first)
expect(result.current).toHaveLength(2)
})
it('FE-VIASTATE-004: every day of the trip is in one list, and no vias is a stable empty one', () => {
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })], 2: [via({ id: 6, day_id: 2 })] } as Record<number, RoadtripVia[]> | undefined },
})
expect(result.current.map(v => v.id)).toEqual([5, 6])
rerender({ byDay: undefined })
const empty = result.current
expect(empty).toEqual([])
rerender({ byDay: {} })
expect(result.current).toBe(empty)
})
})
@@ -0,0 +1,30 @@
import { useRef } from 'react'
import type { RoadtripVia } from '@trek/shared'
/**
* The trip's via points as an array that only changes when a via does.
*
* Both map renderers rebuild their via handles whenever this list changes, and the GL one
* replaces the DOM element outright. The list it was given changed on every render: the
* hook that loads vias returns a fresh object each time, and a fresh object arrives again
* on every reload even when the rows came back identical.
*
* That made dragging a freshly placed via fail on the first try. Placing one triggers a
* re-route, the re-route lands about a second later, the render it causes replaced the
* handle under the pointer — and a pointerdown belongs to the element it happened on, so
* the drag went to an element that no longer existed. The second attempt worked because
* by then nothing was pending. An older via never showed it: nothing was re-routing.
*
* Keyed by id and position because those are the only fields a handle is built from. A
* via that genuinely moved gets a new list, which is what redraws it where it now is.
*/
export function useStableVias(viasByDay: Record<number, RoadtripVia[]> | undefined): RoadtripVia[] {
const flat = Object.values(viasByDay ?? {}).flat()
const key = flat.map(v => `${v.id}@${v.lat},${v.lng}`).join('|')
// Read and written during render on purpose: this is a cache of the argument, not
// state — it never triggers a render of its own, and it has to be settled before the
// effects that consume the list run.
const held = useRef<{ key: string; list: RoadtripVia[] }>({ key: '', list: [] })
if (held.current.key !== key) held.current = { key, list: flat }
return held.current.list
}
+16
View File
@@ -353,6 +353,22 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
}
}
else if (r.type === 'car') {
// A rental with stops is a drive, and the printout is what people take
// into the car (#1797). Without this the route reads as pick-up and return
// with everything in between missing.
//
// Consecutive repeats collapse, and a chain that names one place only is
// dropped: the ordinary rental is picked up and returned at the same desk,
// which satisfies "two endpoints" and would otherwise print the counter's
// name twice with an arrow between. A genuine round trip keeps both ends,
// because Dresden sits between them.
const stops = (r.endpoints || []).slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map(e => e.name)
.filter((name, i, all) => name && name !== all[i - 1])
subtitle = new Set(stops).size >= 2 ? stops.join(' → ') : ''
}
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { MapPin } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
interface Props {
value: string
@@ -16,6 +17,8 @@ interface Props {
// is never lost, and picking a suggestion just replaces the text (#1496).
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
const { t, locale } = useTranslation()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias()
const [open, setOpen] = useState(false)
const [results, setResults] = useState<any[]>([])
const [highlight, setHighlight] = useState(-1)
@@ -47,7 +50,7 @@ export default function AddressInput({ value, onChange, placeholder, className }
const myReq = ++reqIdRef.current
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale)
const data = await mapsApi.search(trimmed, locale, locationBias)
if (myReq !== reqIdRef.current) return
setResults(data.places || [])
setHighlight(-1)
@@ -1388,39 +1388,90 @@ describe('DayPlanSidebar', () => {
expect(onDeletePlace).toHaveBeenCalledWith(42)
})
// ── Note card edit/delete buttons ─────────────────────────────────────
// ── Note card editing (#2249) ─────────────────────────
it('FE-PLANNER-DAYPLAN-064: note card edit button calls openEditNote', async () => {
it('FE-PLANNER-DAYPLAN-064: clicking a note row opens its edit modal', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
mockDayNotesState.dayNotes = { '10': [note] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
// Find note edit button (Pencil in note-edit-buttons)
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
if (noteEditBtns.length > 0) {
await user.click(noteEditBtns[0] as HTMLElement)
expect(mockDayNotesState.openEditNote).toHaveBeenCalled()
}
await user.click(cardRow(screen.getByText('My note')))
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
})
it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => {
// The bug: an absolutely-positioned pencil/trash pill sat on the note's own
// reorder chevrons, and a coarse-pointer `opacity: 1 !important` in index.css
// made it permanent on tablets. Nothing may float over that column again.
it('FE-PLANNER-DAYPLAN-064b: the note row carries no floating action pill over its reorder buttons', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const row = cardRow(screen.getByText('My note'))
expect(document.querySelector('.note-edit-buttons')).toBeNull()
expect(row.querySelectorAll('[style*="position: absolute"]')).toHaveLength(0)
expect(row.querySelectorAll('.reorder-buttons button')).toHaveLength(2)
// Positional, not just by class: the reorder column owns the row's right edge.
expect(row.lastElementChild).toHaveClass('reorder-buttons')
})
it('FE-PLANNER-DAYPLAN-064e: the keyboard reaches the note and opens it with Enter', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const row = cardRow(screen.getByText('My note'))
expect(row).toHaveAttribute('tabindex', '0')
// A press-scale on a draggable row fights the drag, so the row opts out (#2158).
expect(row).toHaveAttribute('data-no-press')
fireEvent.keyDown(row, { key: 'Enter' })
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
})
it('FE-PLANNER-DAYPLAN-064c: a link inside the note body opens the link, not the edit modal', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
mockDayNotesState.dayNotes = { '10': [note] }
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note', time: '[docs](https://example.com)' })] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
// Find note delete button (Trash2 in note-edit-buttons)
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
if (noteEditBtns.length > 1) {
await user.click(noteEditBtns[1] as HTMLElement)
// Clicking delete opens a confirmation dialog rather than deleting immediately.
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
expect(screen.getByText('Delete note?')).toBeInTheDocument()
// Confirming triggers the actual delete.
await user.click(screen.getByRole('button', { name: /^delete$/i }))
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
}
await user.click(screen.getByRole('link', { name: 'docs' }))
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYPLAN-064d: the reorder chevrons still move the note instead of editing it', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [
buildDayNote({ id: 55, day_id: 10, text: 'First note', sort_order: 0 }),
buildDayNote({ id: 56, day_id: 10, text: 'Second note', sort_order: 1 }),
] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const row = cardRow(screen.getByText('Second note'))
await user.click(row.querySelectorAll('.reorder-buttons button')[0] as HTMLElement)
expect(mockDayNotesState.moveNote).toHaveBeenCalled()
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYPLAN-065: the note modal deletes only through the confirmation dialog', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
mockDayNotesState.noteUi = { '10': { mode: 'edit', noteId: 55, text: 'My note', time: '', icon: 'FileText', color: null } }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
await user.click(screen.getByRole('button', { name: /^delete$/i }))
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
expect(screen.getByText('Delete note?')).toBeInTheDocument()
const confirm = within(screen.getByText('Delete note?').closest('[class*="z-[10000]"]') as HTMLElement)
await user.click(confirm.getByRole('button', { name: /^delete$/i }))
expect(mockDayNotesState.deleteNote).toHaveBeenCalledWith(10, 55)
// Otherwise the edit modal is left open on a note that no longer exists.
expect(mockDayNotesState.cancelNote).toHaveBeenCalledWith(10)
})
it('FE-PLANNER-DAYPLAN-065b: a note being created offers no delete', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [] }
mockDayNotesState.noteUi = { '10': { mode: 'add', text: '', time: '', icon: 'FileText', color: null } }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
expect(screen.queryByRole('button', { name: /^delete$/i })).toBeNull()
})
// ── Drop on assignment: same-day reorder ─────────────────────────────
@@ -2940,6 +2991,10 @@ describe('DayPlanSidebar', () => {
expect(screen.queryByLabelText('Add Note')).not.toBeInTheDocument()
expect(dragRow(screen.getByText('Read only place'))).toBeNull()
expect(screen.getByText('A note')).toBeInTheDocument()
// A viewer must not reach the edit modal they could never save (#2249).
const noteRow = cardRow(screen.getByText('A note'))
expect(noteRow).not.toHaveAttribute('role', 'button')
expect(noteRow.querySelectorAll('.reorder-buttons')).toHaveLength(0)
})
it('FE-PLANNER-DAYPLAN-132: the read-only chevron still collapses and expands the day', async () => {
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
@@ -696,19 +696,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
})
}
const openEditNote = (dayId: number, note: DayNote, e?: React.MouseEvent) => {
e?.stopPropagation()
_openEditNote(dayId, note)
}
const openEditNote = (dayId: number, note: DayNote) => _openEditNote(dayId, note)
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
e?.stopPropagation()
await _deleteNote(dayId, noteId)
}
const deleteNote = (dayId: number, noteId: number) => _deleteNote(dayId, noteId)
// Unified reorder: assigns positions to ALL item types based on new visual order
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
@@ -2614,17 +2608,33 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
]) : undefined}
{...(canEditDays ? {
role: 'button' as const,
// No press-scale: index.css shrinks every [role=button] on
// :active, which on a draggable row fights the drag (#2158).
'data-no-press': '',
tabIndex: 0,
// The row is the note's edit affordance — the same gesture the
// phone shell already uses (#2249). Links inside the rendered
// body and the reorder buttons keep their own click.
onClick: (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('a, button')) return
openEditNote(day.id, note)
},
onKeyDown: (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) return
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
openEditNote(day.id, note)
},
} : {})}
onMouseEnter={e => {
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '1'
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
if (editBtns) editBtns.style.opacity = '1'
}}
onMouseLeave={e => {
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '0.3'
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
if (editBtns) editBtns.style.opacity = '0'
}}
style={{
position: 'relative',
@@ -2636,7 +2646,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
background: noteSkin.background,
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
transition: 'background 0.1s', cursor: canEditDays ? 'pointer' : 'default', userSelect: 'none',
}}
>
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
@@ -2658,19 +2668,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
)}
</div>
{/* Stacked in a capsule of their own, set off from the text:
a note card is as tall as its body, so the height is free
while the width next to the text is not, and a 10px icon
in a 14px box was barely a target. */}
{/* Floated over the text rather than sharing the row with
it: a note is mostly text, and reserving a column for
two buttons that are invisible until you hover costs
every note a shorter line for the whole time you are
not hovering. */}
{canEditDays && <div className="note-edit-buttons bg-surface-card" style={{ position: 'absolute', right: 5, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, padding: 4, borderRadius: 999, border: '1px solid var(--border-faint)', boxShadow: '0 4px 14px rgba(0,0,0,0.10)', opacity: 0, transition: 'opacity 0.15s' }}>
<button type="button" onClick={e => openEditNote(day.id, note, e)} aria-label={t('dayplan.noteEdit')} title={t('dayplan.noteEdit')} className="text-content-faint note-edit-btn" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Pencil size={15} /></button>
<button type="button" onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} aria-label={t('dayplan.noteDelete')} title={t('dayplan.noteDelete')} className="text-content-faint note-edit-btn note-edit-btn-danger" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Trash2 size={15} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
@@ -2877,6 +2874,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
noteInputRef={noteInputRef}
cancelNote={cancelNote}
saveNote={saveNote}
onRequestDelete={(dayId, noteId) => setPendingDeleteNote({ dayId, noteId })}
t={t}
/>
@@ -2892,7 +2890,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<ConfirmDialog
isOpen={!!pendingDeleteNote}
onClose={() => setPendingDeleteNote(null)}
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
onConfirm={() => {
if (!pendingDeleteNote) return
// Close the edit modal behind the confirm; leaving it open would go on
// editing a note that no longer exists.
cancelNote(pendingDeleteNote.dayId)
deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId)
}}
title={t('dayplan.confirmDeleteNoteTitle')}
message={t('dayplan.confirmDeleteNoteBody')}
/>
@@ -11,6 +11,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
interface NoteModalUi {
mode: 'add' | 'edit'
noteId?: number
icon: string
text: string
time: string
@@ -25,6 +26,8 @@ interface DayPlanSidebarNoteModalProps {
noteInputRef: React.RefObject<HTMLInputElement>
cancelNote: (dayId: number) => void
saveNote: (dayId: number) => void
/** Asks the planner to confirm and then delete the note being edited (#2249). */
onRequestDelete: (dayId: number, noteId: number) => void
t: (key: string, params?: Record<string, unknown>) => string
}
@@ -38,19 +41,19 @@ const BODY_MAX = 2000
* right. The preview is the reason the colour picker is worth having — a swatch
* row tells you nothing about what a tinted card looks like next to a place.
*/
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, t }: DayPlanSidebarNoteModalProps) {
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: DayPlanSidebarNoteModalProps) {
return (
<>
{Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi} noteInputRef={noteInputRef}
cancelNote={cancelNote} saveNote={saveNote} t={t} />,
cancelNote={cancelNote} saveNote={saveNote} onRequestDelete={onRequestDelete} t={t} />,
document.body
))}
</>
)
}
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
dayId: string
ui: NoteModalUi
}) {
@@ -174,7 +177,17 @@ function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote,
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{/* Delete lives here rather than on the row: the row's floating pencil/trash
pill covered the reorder chevrons and, on a coarse pointer, never went
away (#2249). The planner still confirms before it deletes. */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
{ui.mode === 'edit' && ui.noteId != null && (
<button type="button" onClick={() => onRequestDelete(Number(dayId), ui.noteId!)}
className="text-danger"
style={{ marginRight: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.delete')}
</button>
)}
<button type="button" onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={() => saveNote(Number(dayId))} disabled={!canSave}
className={!canSave ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { MapPin, X } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
export interface LocationPoint {
name: string
@@ -19,6 +20,8 @@ interface Props {
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
const { t, locale } = useTranslation()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias()
const [query, setQuery] = useState(value?.name || '')
const [open, setOpen] = useState(false)
const [results, setResults] = useState<any[]>([])
@@ -49,7 +52,7 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
debounceRef.current = setTimeout(async () => {
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale)
const data = await mapsApi.search(trimmed, locale, locationBias)
setResults(data.places || [])
setHighlight(-1)
} catch {
@@ -169,6 +169,28 @@ describe('PlaceDetailsColumn', () => {
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
})
it('FE-PDC-010b: credits a website description by host', async () => {
// The source labels used to be a ternary chain ending in 'Wikipedia', so a
// restaurant's own marketing copy would have been credited to an
// encyclopaedia. There is no fixed name for "the place's own website", and
// the host is the honest one.
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: {
text: 'Pizza in Rostock, seit 2015.',
source: 'website',
sourceUrl: 'https://www.losteria.net/rostock',
license: null,
},
})
renderColumn()
const link = await screen.findByRole('link', { name: /losteria\.net/ })
expect(link).toHaveAttribute('href', 'https://www.losteria.net/rostock')
expect(screen.queryByText(/Wikipedia/)).not.toBeInTheDocument()
})
it('FE-PDC-011: says so when the admin switched enrichment off', async () => {
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [], disabled: true })
renderColumn()
@@ -379,6 +379,37 @@ function PhotoTile({
)
}
/**
* Who the description came from, as the reader would name them.
*
* `website` has no fixed name — it is the place's own site — so it is credited
* by host: "losteria.net" says where the text is from far better than a generic
* word would. Deliberately not a ternary chain: the previous one fell through
* to 'Wikipedia', which would have credited a restaurant's marketing copy to an
* encyclopaedia.
*/
function descriptionSourceLabel(
source: NonNullable<MapsPlaceEnrichmentResult['description']>['source'],
sourceUrl: string | null,
): string {
switch (source) {
case 'google':
return 'Google'
case 'osm':
return 'OpenStreetMap'
case 'wikivoyage':
return 'Wikivoyage'
case 'wikipedia':
return 'Wikipedia'
case 'website':
try {
return new URL(sourceUrl || '').hostname.replace(/^www\./, '')
} catch {
return 'Website'
}
}
}
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google'
if (source === 'wikipedia') return 'Wikipedia'
@@ -659,14 +690,7 @@ function DescriptionBlock({
}): React.ReactElement | null {
if (!description) return null
const sourceLabel =
description.source === 'google'
? 'Google'
: description.source === 'osm'
? 'OpenStreetMap'
: description.source === 'wikivoyage'
? 'Wikivoyage'
: 'Wikipedia'
const sourceLabel = descriptionSourceLabel(description.source, description.sourceUrl)
// A description of the chain is not a description of this place, and the
// heading is where that gets said. Without it the reader has no way to tell
@@ -1,3 +1,5 @@
import type { RoadtripStopType } from '@trek/shared'
export interface PlaceFormData {
name: string
description: string
@@ -24,6 +26,14 @@ export interface PlaceFormData {
// submit payload when unchanged, and useTripPlanner strips it off the place
// update and PUTs it per assignment instead.
assignment_notes?: string
// What kind of stop on a drive this is, if any (#1797). Optional and absent from
// DEFAULT_FORM for the same reason as image_url: a missing key writes nothing, and a
// place created outside a road trip has no opinion about fuel stops.
stop_type?: RoadtripStopType | null
// Offered only while creating, and only alongside a stop_type: the corridor popup
// suggests how long that kind of pause usually takes, and the full form should not
// throw the suggestion away on the way through. Editing a stay is the rail's job.
duration_minutes?: number
}
export function isGoogleMapsUrl(input: string): boolean {
+100 -32
View File
@@ -1,13 +1,16 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import Modal from '../shared/Modal'
import type { RoadtripStopType } from '@trek/shared'
import { STOP_KINDS } from '../Roadtrip/stopKinds'
import CustomSelect from '../shared/CustomSelect'
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import { mapsApi } from '../../api/client'
import { recordPlacePick } from '../../api/placeShadow'
import { useAuthStore } from '../../store/authStore'
import { useAddonStore } from '../../store/addonStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
import { useToast } from '../shared/Toast'
@@ -16,6 +19,7 @@ import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError'
import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { Place, Category, Assignment, BudgetItem } from '../../types'
@@ -36,7 +40,7 @@ interface PlaceFormModalProps {
onClose: () => void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
place: Place | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null
tripId: number
categories: Category[]
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
@@ -86,9 +90,20 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
onOpenExpense,
} = props
// Hidden while the addon is off, because the kinds only mean anything to the road trip
// rail: on an instance without it they would be six labels that change nothing.
const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip'))
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
/**
* What produced the list currently on screen, kept for the shadow log: the
* query as typed and the provider the envelope named. A ref rather than
* state because nothing renders from it and a pick must read the value that
* belonged to the list, not a value a re-render replaced.
*/
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewCategory, setShowNewCategory] = useState(false)
@@ -143,6 +158,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
// Carried through every edit. Without it, opening a fuel stop to fix a typo
// submits an empty kind and turns it back into a numbered destination.
// duration_minutes deliberately stays out: how long a stay takes belongs to the
// rail's own dialog, and sending it from here would overwrite what was set there.
stop_type: place.stop_type ?? null,
// The day-specific note rides only with an assignment in context (#2163);
// otherwise the key stays absent so submit never sends a notes write.
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
@@ -157,6 +177,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
website: prefillCoords.website || '',
phone: prefillCoords.phone || '',
osm_id: prefillCoords.osm_id,
stop_type: prefillCoords.stop_type ?? null,
duration_minutes: prefillCoords.duration_minutes,
})
} else {
setForm(DEFAULT_FORM)
@@ -213,32 +235,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
}, [isOpen])
// Derive location bias bounding box from the trip's existing places
const places = useTripStore((s) => s.places)
const locationBias = useMemo(() => {
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
if (withCoords.length === 0) return undefined
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
for (const p of withCoords) {
const lat = Number(p.lat), lng = Number(p.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
if (lat < minLat) minLat = lat
if (lat > maxLat) maxLat = lat
if (lng < minLng) minLng = lng
if (lng > maxLng) maxLng = lng
}
if (!Number.isFinite(minLat)) return undefined
// Skip bias if the bounding box is too large (~500 km diagonal)
const dlat = maxLat - minLat
const dlng = maxLng - minLng
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
if (diagKm > 500) return undefined
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
}, [places])
// Where the trip is happening — the hint that tells the search which of a
// thousand identically named places is meant. Autocomplete wants a box, the
// search wants a point; useLocationBias derives both from the same places.
const { box: locationBias, point: locationBiasPoint } = useLocationBias()
// Autocomplete fetch — aborts any in-flight request before starting a new one
const fetchSuggestions = useCallback(async (query: string) => {
@@ -252,6 +253,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
acAbortRef.current = controller
try {
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
acMetaRef.current = { query, source: result.source || 'unknown' }
setAcSuggestions(result.suggestions || [])
setAcHighlight(-1)
} catch (err: unknown) {
@@ -310,7 +312,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return
}
}
const result = await mapsApi.search(mapsSearch, language)
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
setMapsResults(result.places || [])
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
@@ -319,7 +322,13 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
}
const handleSelectMapsResult = (result) => {
/**
* `pick` is present only when the click came from a ranked list. The
* collection picker and the autocomplete detour reach this function with a
* place that was never ranked against a query, and a made-up rank would be
* worse than no row at all.
*/
const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
setForm(prev => mergeResult(prev, result, autoFilledRef.current))
// The one point every pick flows through, so the detail column hangs here.
// A new pick drops whatever hero image belonged to the previous place.
@@ -336,12 +345,36 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
details: result,
})
setForm(prev => ({ ...prev, image_url: undefined }))
if (pick) {
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
if (meta) {
recordPlacePick({
query: meta.query,
lang: language,
// The bias the search actually ran under is a box around the trip's
// existing places; the corpus stores its centre, which is what an
// evaluation needs to bias its own index the same way.
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
source: `${pick.mode}:${meta.source}`,
liveRank: pick.rank,
liveCount: pick.count,
pickedName: result.name || '',
pickedLat: lat,
pickedLng: lng,
pickedPlaceId: result.google_place_id || result.osm_id || null,
})
}
}
}
setMapsResults([])
setMapsSearch('')
}
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
// Read before the list is cleared: this is the rank the user saw.
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
const acCount = acSuggestions.length
setAcSuggestions([])
setAcHighlight(-1)
const previousSearch = mapsSearch
@@ -368,11 +401,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
if (!place) {
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(query, language)
const search = await mapsApi.search(query, language, locationBiasPoint)
place = search.places?.[0] ?? null
}
if (place) {
handleSelectMapsResult(place)
handleSelectMapsResult(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
} else {
setMapsSearch(previousSearch)
toast.error(t('places.mapsSearchError'))
@@ -479,6 +512,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lat: form.lat ? Number.parseFloat(form.lat) : null,
lng: form.lng ? Number.parseFloat(form.lng) : null,
category_id: form.category_id || null,
// An explicit null is how a stop stops being a fuel stop; the service reads it
// that way rather than as "leave alone", which is what a missing key means.
stop_type: form.stop_type || null,
// Only on the way in, and only with a kind: it is the popup's suggestion for how
// long that kind of pause takes. On an edit it is left out entirely, because the
// stay belongs to the rail's dialog and sending it here would overwrite it.
...(!place && form.stop_type && form.duration_minutes
? { duration_minutes: form.duration_minutes }
: {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
}
// #2163: the per-assignment note only travels when an assignment is in
@@ -527,6 +569,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -599,6 +642,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -768,7 +812,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
<button
key={idx}
type="button"
onClick={() => handleSelectMapsResult(result)}
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
>
<div className="font-medium text-sm">{result.name}</div>
@@ -870,6 +914,30 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
</div>
</div>
{/* What kind of stop this is on a drive.
Under the category rather than beside it, because the two answer different
questions: a category is the traveller's own label, editable and shared across
the whole instance, while this is a fact about the place. Refuelling is not a
taste. Shown always, not only in road trip mode: a fuel stop added on a Tuesday
is still a fuel stop when the mode is switched on later, and this is the only
place in the web UI where the kind can be taken off again. */}
{roadtripEnabled ? (
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('roadtrip.stop.kind')}</label>
<CustomSelect
value={form.stop_type ?? ''}
onChange={value => handleChange('stop_type', String(value) as RoadtripStopType | '')}
placeholder={t('roadtrip.stop.none')}
options={[
{ value: '', label: t('roadtrip.stop.none') },
...STOP_KINDS.map(k => ({ value: k.key, label: t(k.labelKey) })),
]}
size="sm"
/>
<p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.kindHelp')}</p>
</div>
) : null}
{/* Category */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formCategory')}</label>
@@ -43,6 +43,9 @@ export function ListImportModal(S: SidebarState) {
)}
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginBottom: 16 }}>
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
{/* Same box, same Share button: which screen the link came from is the URL's
business, and until it said so nobody knew a route could be pasted here. */}
{listImportProvider === 'google' ? <div style={{ marginTop: 4 }}>{t('places.googleDirHint')}</div> : null}
</div>
<input
type="text"
@@ -254,7 +254,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
// Hotels link a place through the accommodation record; every other type links
// the picked trip place/activity directly on the reservation (#1353).
place_id: form.type === 'hotel' ? null : (form.place_id || null),
metadata: Object.keys(metadata).length > 0 ? metadata : null,
// An empty object, not null: null clears the column outright, and that
// took the mirrored booking price with it on every edit of a type that
// fills no metadata of its own — restaurant, event, tour, parking, other,
// a hotel without check-in times (#2233). An object still clears what the
// form dropped, and lets the server carry the price across.
metadata,
// Omitted on an edit: the server replaces the endpoint set whenever the
// key is present, and this form never edits endpoints, so sending an
// empty list would drop a transit booking's stations (#2216).
@@ -175,6 +175,29 @@ describe('TransitSearchPanel', () => {
await user.click(await screen.findByText(/London Victoria/))
}
/**
* An empty result is ambiguous without the backend's name: the fallback to
* Transitous is silent, so "no connections" could mean the provider has no
* data here or that the provider you picked never ran (#1699).
*/
it('FE-PLANNER-TRANSIT-006b: the empty state names the backend that answered', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [], provider: 'transitous' })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndLondon(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/No connections found via Transitous/)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-006c: falls back to the unnamed message when the server sends no provider', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndLondon(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/^No connections found\./)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import type { Day, Place, Accommodation } from '../../types'
import type { TransitProvider } from '@trek/shared'
/**
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
@@ -36,6 +37,14 @@ interface TransitPlaceResult { name: string; lat: number; lng: number; type: str
export interface PickedPlace { name: string; lat: number; lng: number }
// Backend names as they are written, not translated — the empty state says
// which one answered so "nothing here" can be told apart from "not the one you
// picked" (#1699).
const PROVIDER_NAMES: Record<TransitProvider, string> = {
transitous: 'Transitous',
google: 'Google',
}
// ── helpers ──────────────────────────────────────────────────────────────────
const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
@@ -340,7 +349,7 @@ interface TransitSearchPanelProps {
}
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
const { t } = useTranslation()
const { t, language } = useTranslation()
const toast = useToast()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
@@ -355,6 +364,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const [loading, setLoading] = useState(false)
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
const [addingIdx, setAddingIdx] = useState<number | null>(null)
const [provider, setProvider] = useState<TransitProvider | null>(null)
// Quick picks: the day's located places, plus the trip's located accommodations.
const quickPicks = useMemo<PickedPlace[]>(() => {
@@ -387,6 +397,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
setLoading(true)
setItineraries(null)
setExpandedIdx(null)
setProvider(null)
try {
const tzFrom = tzAt(from.lat, from.lng)
const tzTo = tzAt(to.lat, to.lng)
@@ -395,7 +406,11 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : tzFrom)
const allModes = activeModes.size === MODE_GROUPS.length
const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes, lang: language })
// Which backend actually answered (#1699). Named in the empty state so
// "nothing found" can be told apart from "the provider you picked never
// ran" — the fallback to Transitous is silent by design.
setProvider(d.provider ?? null)
// MOTIS names the request coordinates START/END — swap in the places the
// user actually picked so walks read "Walk to Zoologischer Garten".
const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n)
@@ -606,7 +621,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
)}
{!loading && ranked && ranked.length === 0 && (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{t('transit.noResults')}
{provider
? t('transit.noResultsVia', { provider: PROVIDER_NAMES[provider] })
: t('transit.noResults')}
</div>
)}
{!loading && ranked && ranked.length > 0 && (
@@ -1192,6 +1192,93 @@ describe('TransportModal', () => {
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
});
it('FE-PLANNER-TRANSMODAL-058: a car booking keeps its stops in order between pick-up and return (#1797)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen Berlin → Prag');
// Pick-up and return are the rental frame; the stops sit between them.
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
// Pick-up and return come first in the form, the stops follow — so the saved
// route is from · stop · stop · to even though the fields read in another order.
const fields = screen.getAllByTestId('location-select');
expect(fields).toHaveLength(4);
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
fireEvent.change(fields[1], { target: { value: 'Prag' } });
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'stop', 'to']);
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Dresden', 'Bastei', 'Prag']);
});
it('FE-PLANNER-TRANSMODAL-060: the stops of a car booking can be put in another order', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const fields = screen.getAllByTestId('location-select');
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
fireEvent.change(fields[1], { target: { value: 'Prag' } });
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
// The order of the stops is the order they are driven, and `sequence` is derived
// from it on save — so without this the only way to swap two was to retype both.
await userEvent.click(screen.getAllByRole('button', { name: /Move down/i })[0]);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Bastei', 'Dresden', 'Prag']);
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
});
it('FE-PLANNER-TRANSMODAL-061: the ends of the stop list cannot be pushed past themselves', async () => {
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
// A single stop has nothing to swap with, so the buttons stay away entirely.
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
expect(screen.queryByRole('button', { name: /Move up/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const up = screen.getAllByRole('button', { name: /Move up/i });
const down = screen.getAllByRole('button', { name: /Move down/i });
expect(up[0]).toBeDisabled();
expect(down[down.length - 1]).toBeDisabled();
});
it('FE-PLANNER-TRANSMODAL-059: a stop removed from a car booking is gone from the saved route', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
await userEvent.click(screen.getByRole('button', { name: /Remove stop/i }));
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
const fields = screen.getAllByTestId('location-select');
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
fireEvent.change(fields[1], { target: { value: 'Prag' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
});
it('FE-PLANNER-TRANSMODAL-057: the open-file link on an attached document does not throw', async () => {
const res = buildReservation({ id: 23, type: 'flight', title: 'LH 400' });
const attached = buildTripFile({ id: 72, original_name: 'boarding.pdf' });
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -116,6 +116,19 @@ interface StationWaypointForm {
seat: string
confirmation_number: string
}
// ── Car stops along the drive ──────────────────────────────────────────────
//
// A rental is one booking with one pick-up and one return, so — unlike a flight or a
// train — the stops in between are not legs of their own: they are places the drive
// passes through, each with an optional time the driver plans to be there. They persist
// as the same `role: 'stop'` endpoints every other transport type already uses.
interface CarStopForm {
location: LocationPoint | null
time: string
}
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
}
@@ -200,6 +213,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
// Train route as an ordered list of stations (origin .. stops .. destination).
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
// A car keeps its pick-up and return as the frame of the rental and gains the stops
// in between (#1797): one booking, one continuous drive, several places along it.
const [carStops, setCarStops] = useState<CarStopForm[]>([])
/** Swaps a stop with its neighbour. Purely local: `sequence` is derived on save. */
const moveCarStop = (index: number, delta: number): void => {
setCarStops(prev => {
const to = index + delta
if (to < 0 || to >= prev.length) return prev
const next = [...prev]
;[next[index], next[to]] = [next[to], next[index]]
return next
})
}
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
@@ -346,6 +373,17 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
} else {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
// Stops persist for every type; only a car offers an editor for them, so only a
// car reads them back into one. The others keep passing theirs through untouched.
setCarStops(
src.type === 'car'
? (src.endpoints ?? [])
.filter(e => e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
: [],
)
}
} else {
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
@@ -354,6 +392,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setToPick({})
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
setCarStops([])
}
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
@@ -504,8 +543,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
})
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
// Keep the itinerary's transfer stops while the route is unchanged (#1065).
const stops = keepTransit
// A car writes the stops the driver planned; every other type keeps passing the
// itinerary's transfer stops through while the route is unchanged (#1065).
const carEndpoints = form.type === 'car'
? carStops
.filter(s => s.location)
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
: []
const stops = keepTransit && form.type !== 'car'
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
: []
stops.forEach((s, i) => endpoints.push({
@@ -513,7 +558,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
}))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
carEndpoints.forEach(e => endpoints.push(e))
const stopCount = stops.length + carEndpoints.length
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
}
// Flights and trains derive their span from the first/last waypoint; other
@@ -543,7 +590,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
location: null,
confirmation_number: form.confirmation_number || null,
notes: form.notes || null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
// An empty object, not null: null clears the column outright, and that
// took the mirrored booking price with it on every edit of a type that
// fills no metadata of its own — restaurant, event, tour, parking, other,
// a hotel without check-in times (#2233). An object still clears what the
// form dropped, and lets the server carry the price across.
metadata,
endpoints,
needs_review: false,
}
@@ -950,6 +1002,76 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div>
</div>
{/* Stops along the drive — cars only (#1797). The rental frame above stays the
pick-up and return; these are the places in between, in order. */}
{form.type === 'car' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label className={labelClass}>{t('roadtrip.stops.label')}</label>
{carStops.map((stop, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
{/* The order of these stops IS the route: `sequence` is the array index
at save time, so the only way to change which one is driven first
was to delete both and re-enter them. Buttons rather than dragging,
because the row already carries a location picker and a time picker
and there is nothing left to grab. */}
{carStops.length > 1 && (
<div style={{ display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
<button
type="button"
onClick={() => moveCarStop(i, -1)}
disabled={i === 0}
aria-label={t('dayplan.moveUp')}
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
style={{ background: 'none', border: 'none', cursor: i === 0 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
>
<ChevronUp size={13} />
</button>
<button
type="button"
onClick={() => moveCarStop(i, 1)}
disabled={i === carStops.length - 1}
aria-label={t('dayplan.moveDown')}
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
style={{ background: 'none', border: 'none', cursor: i === carStops.length - 1 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
>
<ChevronDown size={13} />
</button>
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<LocationSelect
value={stop.location}
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
/>
</div>
<div style={{ width: 110, flexShrink: 0 }}>
<CustomTimePicker
value={stop.time}
onChange={v => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
/>
</div>
<button
type="button"
onClick={() => setCarStops(prev => prev.filter((_, j) => j !== i))}
aria-label={t('roadtrip.stops.remove')}
className="text-content-faint hover:text-danger"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 8, display: 'flex', alignItems: 'center' }}
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
onClick={() => setCarStops(prev => [...prev, emptyCarStop()])}
className="text-content-faint hover:text-content-secondary"
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}
>
<Plus size={12} /> {t('reservations.layover.addStop')}
</button>
</div>
)}
{/* Departure row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -0,0 +1,145 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import RoadtripAlternativesBar from './RoadtripAlternativesBar'
import type { AlternativeOverlay } from './alternativeOverlays'
import type { LegAlternatives } from './useRouteAlternatives'
/**
* FE-ALTBAR-001..009 — the ways of driving one leg, offered over the map.
*
* Every value shown comes from the same overlay the map draws. Working them out
* here separately is what once made the list label the driven route "Fastest"
* while offering another one as quicker beside it, because it assumed the first
* entry was the quickest — which stopped being true the moment the road
* currently driven was put at the top.
*/
const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay => ({
index: 0,
coordinates: [],
color: '#0a84ff',
label: '3 h',
note: '',
duration: 10_800,
distance: 290_000,
slowerThanQuickest: 0,
labelBg: '#0a84ff',
at: { lat: 53, lng: 11 },
...over,
})
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => ({
dayId: 4, index: 1, routes: [], loading: false, error: false, ...over,
})
beforeEach(() => {
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
})
describe('RoadtripAlternativesBar', () => {
it('FE-ALTBAR-001: nothing open means nothing drawn', () => {
const { container } = render(
<RoadtripAlternativesBar open={null} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(container).toBeEmptyDOMElement()
})
it('FE-ALTBAR-002: while the router is answering it says so rather than showing an empty list', () => {
render(
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(screen.getByText(/asking the router/i)).toBeInTheDocument()
})
it('FE-ALTBAR-003: a router that will not answer says so, and offers nothing', () => {
render(
<RoadtripAlternativesBar open={leg({ error: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(screen.getByText(/not answering/i)).toBeInTheDocument()
expect(screen.queryAllByRole('button', { name: /km/ })).toHaveLength(0)
})
it('FE-ALTBAR-004: one route back is not a choice, and is said as one', () => {
// Offering a single option would ask the traveller to pick the road they are
// already on.
render(
<RoadtripAlternativesBar open={leg()} overlays={[overlay()]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(screen.getByText(/only/i)).toBeInTheDocument()
})
it('FE-ALTBAR-005: each offer shows its distance and what it is', () => {
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, distance: 310_000, note: 'No motorway' })]}
onChoose={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('Fastest')).toBeInTheDocument()
expect(screen.getByText('No motorway')).toBeInTheDocument()
expect(screen.getByText(/290/)).toBeInTheDocument()
expect(screen.getByText(/310/)).toBeInTheDocument()
})
it('FE-ALTBAR-006: an offer with nothing to say about itself says how much slower it is', () => {
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, note: '', slowerThanQuickest: 1800 })]}
onChoose={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText(/30/)).toBeInTheDocument()
})
it('FE-ALTBAR-007: choosing one reports its own index, not its position', () => {
// The map and the list agree on `index`; a position would drift the moment
// the driven road is prepended.
const onChoose = vi.fn()
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ index: 3 }), overlay({ index: 7 })]}
onChoose={onChoose}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByRole('button')[2])
expect(onChoose).toHaveBeenCalledWith(7)
})
it('FE-ALTBAR-008: pointing at an offer lights that road up, and leaving clears it', () => {
const onHighlight = vi.fn()
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ index: 0 }), overlay({ index: 1 })]}
onChoose={vi.fn()}
onClose={vi.fn()}
onHighlight={onHighlight}
/>,
)
const first = screen.getAllByRole('button')[1]
fireEvent.mouseEnter(first)
expect(onHighlight).toHaveBeenLastCalledWith(0)
fireEvent.mouseLeave(first)
expect(onHighlight).toHaveBeenLastCalledWith(null)
// Keyboard too: the same road lights up on focus.
fireEvent.focus(first)
expect(onHighlight).toHaveBeenLastCalledWith(0)
})
it('FE-ALTBAR-009: closing is always available, even while it is still asking', () => {
const onClose = vi.fn()
render(
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={onClose} />,
)
fireEvent.click(screen.getByRole('button', { name: /close/i }))
expect(onClose).toHaveBeenCalled()
})
})
@@ -0,0 +1,101 @@
import React from 'react'
import { Shuffle, X, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import { formatDurationShort } from './roadtripModel'
import type { LegAlternatives } from './useRouteAlternatives'
import type { AlternativeOverlay } from './alternativeOverlays'
interface RoadtripAlternativesBarProps {
open: LegAlternatives | null
/** The very rows the map draws, so the two can never disagree about which is fastest. */
overlays: AlternativeOverlay[]
onChoose: (index: number) => void
onClose: () => void
/** Reports which option the pointer is on, so the map can light that road up. */
onHighlight?: (index: number | null) => void
}
/**
* The ways of driving one leg, offered over the map.
*
* A bar rather than a dialog: the answer is on the map behind it — which road each option
* takes — so covering the map to ask about it would hide the only thing worth looking at.
*
* Every value here comes from the same overlay the map draws. Working them out separately
* is what made the list label the driven route "Fastest" while offering another one
* "58 min quicker" beside it: it assumed the first entry was the quickest, which stopped
* being true the moment the road currently driven was put at the top.
*/
export default function RoadtripAlternativesBar({
open, overlays, onChoose, onClose, onHighlight,
}: RoadtripAlternativesBarProps): React.ReactElement | null {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
if (!open) return null
return (
<div className="pointer-events-auto flex max-w-[min(92vw,640px)] flex-col gap-2 rounded-2xl border border-edge-faint bg-surface-elevated px-3 py-2.5 shadow-modal backdrop-blur">
<div className="flex items-center gap-2">
<Shuffle size={13} className="shrink-0 text-content-faint" aria-hidden />
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.alt.title')}
</span>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="ms-auto rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
>
<X size={13} aria-hidden />
</button>
</div>
{open.loading ? (
<p className="text-caption text-content-muted">{t('roadtrip.alt.loading')}</p>
) : open.error ? (
<p className="flex items-start gap-1.5 text-caption text-warning">
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
{t('roadtrip.alt.failed')}
</p>
) : overlays.length < 2 ? (
// One route back means there is genuinely only one sensible way to drive it.
<p className="text-caption text-content-muted">{t('roadtrip.alt.onlyOne')}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{overlays.map(alt => (
<button
key={alt.index}
type="button"
onClick={() => onChoose(alt.index)}
onMouseEnter={() => onHighlight?.(alt.index)}
onMouseLeave={() => onHighlight?.(null)}
onFocus={() => onHighlight?.(alt.index)}
onBlur={() => onHighlight?.(null)}
className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint"
>
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
// theme-lint-disable — the very colour this route is drawn in on the map;
// a token here would break the one link between list and picture.
style={{ background: alt.color }}
aria-hidden
/>
<span className="flex flex-col">
<span className="text-caption font-medium tabular-nums text-content">
{formatDistance(alt.distance / 1000, distanceUnit)}
</span>
<span className="text-caption tabular-nums text-content-muted">
{alt.note
? alt.note
: t('roadtrip.alt.slower', { time: formatDurationShort(alt.slowerThanQuickest) })}
</span>
</span>
</button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,93 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { Fuel, Zap, Utensils } from 'lucide-react'
import { fireEvent, render, screen, within } from '../../../tests/helpers/render'
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
/**
* FE-CATPICK-001..008 — what the corridor is asked to look for.
*
* Multi-select, so it stays open on a click: picking "fuel and charging" is one
* gesture rather than two round trips through a menu. The summary names the
* kinds rather than counting them, because "Fuel, Charging" is the answer and
* "2 kinds" is a riddle.
*/
const KEYS = ['fuel', 'charging', 'food'] as const
const META = {
fuel: { labelKey: 'roadtrip.poi.fuel', Icon: Fuel },
charging: { labelKey: 'roadtrip.poi.charging', Icon: Zap },
food: { labelKey: 'roadtrip.poi.food', Icon: Utensils },
}
function picker(selected: string[] = [], onToggle = vi.fn()) {
render(<RoadtripCategoryPicker keys={KEYS} meta={META} selected={selected} onToggle={onToggle} />)
return { onToggle, trigger: () => screen.getByRole('button', { expanded: false }) }
}
/** The control that opens the list, whatever its current state. */
const trigger = () => screen.getAllByRole('button')[0]
describe('RoadtripCategoryPicker', () => {
it('FE-CATPICK-001: closed by default, so the column belongs to the results', () => {
picker()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('FE-CATPICK-002: with nothing picked it asks rather than reporting an empty set', () => {
picker()
expect(screen.getByText('Looking for')).toBeInTheDocument()
})
it('FE-CATPICK-003: the summary names the kinds, in the order they are offered', () => {
// Reversed on the way in: the control lists them the way it offers them, not
// the way they happened to be clicked.
picker(['food', 'fuel'])
expect(screen.getByText('Fuel, Food')).toBeInTheDocument()
})
it('FE-CATPICK-004: opening lists every kind', () => {
picker()
fireEvent.click(trigger())
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
for (const label of ['Fuel', 'Charging', 'Food']) {
expect(screen.getAllByText(label).length).toBeGreaterThan(0)
}
})
it('FE-CATPICK-005: picking one reports it and leaves the list open', () => {
// The whole reason it is multi-select: two kinds is one gesture.
const { onToggle } = picker()
fireEvent.click(trigger())
fireEvent.click(screen.getByText('Charging'))
expect(onToggle).toHaveBeenCalledWith('charging')
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
})
it('FE-CATPICK-006: picking a kind that is already on reports it too, for the toggle', () => {
const { onToggle } = picker(['fuel'])
fireEvent.click(trigger())
fireEvent.click(screen.getAllByText('Fuel')[1] ?? screen.getByText('Fuel'))
expect(onToggle).toHaveBeenCalledWith('fuel')
})
it('FE-CATPICK-007: a click outside closes it, a click inside does not', () => {
picker()
fireEvent.click(trigger())
fireEvent.mouseDown(trigger())
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
fireEvent.mouseDown(document.body)
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('FE-CATPICK-008: Escape closes it', () => {
picker()
fireEvent.click(trigger())
fireEvent.keyDown(document, { key: 'Escape' })
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
})
@@ -0,0 +1,123 @@
import React, { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { serviceColor } from './roadtripModel'
import { FS } from './typeScale'
interface RoadtripCategoryPickerProps {
/** Every kind the corridor can look for, in the order they are offered. */
keys: readonly string[]
meta: Record<string, { labelKey: string; Icon: LucideIcon }>
selected: readonly string[]
onToggle: (key: string) => void
}
/**
* What to look for, as one full-width control.
*
* Six pills wrapped onto three lines and took the height of the whole card for a set of
* answers that rarely changes. Folded into a dropdown it is one line, and the space goes
* to the results — which is what the column is actually for.
*
* Multi-select, so it stays open on a click: picking "fuel and charging" is one gesture
* rather than two round trips through a menu. Each row leads with its own coloured icon,
* the same one the rail puts on its dashed line and the map puts on the route.
*/
export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle }: RoadtripCategoryPickerProps): React.ReactElement {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointer = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [open])
// The kinds themselves rather than a count: "Fuel, Charging" is the answer, "2 kinds"
// is a riddle. Truncated when it runs long, which is what the icons in front are for.
const summary = selected.length
? keys.filter(k => selected.includes(k)).map(k => t(meta[k].labelKey)).join(', ')
: t('roadtrip.poi.looking')
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
aria-haspopup="listbox"
style={{ fontSize: FS.control }}
className="flex h-[32px] w-full items-center gap-2 rounded-xl border border-edge bg-surface-card pe-2 ps-2.5 text-start transition-colors hover:border-content-faint"
>
<span className="flex shrink-0 items-center gap-1">
{keys.filter(k => selected.includes(k)).slice(0, 3).map(k => {
const Icon = meta[k].Icon
return (
<span
key={k}
className="grid h-[18px] w-[18px] place-items-center rounded-md"
// theme-lint-disable — the road-signage palette in `roadtripModel`.
style={{ background: serviceColor(k), color: '#fff' }} // theme-lint-disable — road-signage palette
>
<Icon size={10} strokeWidth={2.2} aria-hidden />
</span>
)
})}
</span>
<span className={`min-w-0 flex-1 truncate ${selected.length ? 'font-medium text-content' : 'text-content-faint'}`}>
{summary}
</span>
<ChevronDown
size={14}
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
aria-hidden
/>
</button>
{open ? (
<div
role="listbox"
aria-multiselectable
className="absolute inset-x-0 top-full z-[var(--z-toast)] mt-1 overflow-hidden rounded-xl border border-edge bg-surface-elevated shadow-dropdown backdrop-blur"
>
{keys.map(key => {
const { labelKey, Icon } = meta[key]
const on = selected.includes(key)
return (
<button
key={key}
type="button"
role="option"
aria-selected={on}
// Stays open: picking two kinds is one gesture, not two.
onClick={() => onToggle(key)}
style={{ fontSize: FS.control }}
className="flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
>
<span
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
style={on ? { background: serviceColor(key), color: '#fff' } : { background: `${serviceColor(key)}1f`, color: serviceColor(key) }} // theme-lint-disable — road-signage palette
>
<Icon size={12} strokeWidth={2} aria-hidden />
</span>
<span className={`min-w-0 flex-1 truncate ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
{t(labelKey)}
</span>
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
</button>
)
})}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,349 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, within, fireEvent } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import RoadtripCorridorPanel from './RoadtripCorridorPanel'
import type { CorridorPoi } from './useCorridorPois'
import type { RoadtripCorridor } from './useRoadtripCorridor'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
const stop = (id: number, name: string): RoadtripStop => ({
assignmentId: id,
placeId: id * 10,
name,
lat: 53.5,
lng: 9.9,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
})
const day = (dayId: number, dayNumber: number): RoadtripDay => ({
dayId,
dayNumber,
date: null,
title: null,
stops: [stop(dayId * 10 + 1, 'Hamburg'), stop(dayId * 10 + 2, 'Berlin')],
legs: [],
schedule: { entries: [], warnings: [] },
legVias: [], driveWarnings: [], dayWarning: null,
// Routed. Searching before the day has a geometry builds the corridor from the
// straight line between the stops, and the routing answer landing a second
// later clears the search mid-flight — so the button waits for this.
geometry: [[53.55, 9.99], [52.52, 13.4]] as [number, number][],
distance: 0,
duration: 0,
})
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days,
lines: [],
accessLines: [],
vias: [],
segments: [],
totalDistance: 0,
totalDuration: 0,
totalStops: days.length * 2,
quietDays: [],
loading: false,
})
function poi(over: Partial<CorridorPoi> & { osm_id: string; name: string }): CorridorPoi {
return {
lat: 53.5,
lng: 9.9,
category: 'fuel',
poi_type: 'amenity=fuel',
address: null,
website: null,
phone: null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap',
offRouteKm: 1.2,
alongKm: 40,
...over,
} as CorridorPoi
}
function corridor(over: Partial<RoadtripCorridor> = {}, search: Partial<RoadtripCorridor['search']> = {}): RoadtripCorridor {
const days = over.day ? [over.day] : [day(1, 1)]
const searchState: RoadtripCorridor['search'] = {
results: [],
progress: { done: 0, total: 0 },
loading: false,
capped: false,
failedAreas: 0,
truncatedAreas: 0,
error: false,
spine: [],
search: vi.fn(),
clear: vi.fn(),
...search,
}
return {
dayId: String(days[0].dayId),
setDayId: vi.fn(),
day: days[0],
categories: ['fuel'],
toggleCategory: vi.fn(),
widthKm: 5,
setWidthKm: vi.fn(),
nameFilter: '',
anchors: [],
section: null,
setSection: vi.fn(),
sectionKm: 50,
setSectionKm: vi.fn(),
socketFilter: '',
setSocketFilter: vi.fn(),
minKw: 0,
setMinKw: vi.fn(),
setNameFilter: vi.fn(),
// Same as the hook with an empty filter: everything found is on show.
visible: searchState.results,
insertIndexFor: vi.fn(() => 1),
stopsAlongKm: [0, 100],
...over,
search: searchState,
}
}
describe('RoadtripCorridorPanel', () => {
it('FE-ROADTRIP-PANEL-001: searching is a button, never a side effect of opening the panel', () => {
const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(c.search.search).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: /search/i }))
expect(c.search.search).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-PANEL-004: a day that has not routed yet cannot be searched along', () => {
// Until the day routes, the corridor is the straight line between the stops,
// so the boxes march across whatever lies between them instead of along the
// roads driven. Worse, the routing answer landing a second later changes the
// line, which clears the search mid-flight and drops every result with no
// error and no explanation. Routing runs one day at a time, about a second
// apart, so on a long trip that window is wide open.
const unrouted = { ...day(1, 1), geometry: [] as [number, number][] }
const c = corridor({ day: unrouted })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([unrouted])} />)
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-005: nor while the routing is still running', () => {
const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={{ ...routes([day(1, 1)]), loading: true }} />)
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-002: nothing selected means nothing to search for', () => {
const c = corridor({ categories: [] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-003: a day with a single stop has no drive to search along', () => {
const lonely = { ...day(1, 1), stops: [stop(11, 'Hamburg')] }
const c = corridor({ day: lonely })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([lonely])} />)
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-004: toggling a kind reports which one', () => {
const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
// Six kinds used to be six pills wrapped over three lines; they are one dropdown now,
// so the options only exist once it is open.
fireEvent.click(screen.getByRole('button', { expanded: false }))
fireEvent.click(screen.getByRole('option', { name: /campsite/i }))
expect(c.toggleCategory).toHaveBeenCalledWith('campsite')
// What is already selected reads as selected rather than only looking different.
expect(screen.getByRole('option', { name: /fuel/i })).toHaveAttribute('aria-selected', 'true')
})
it('FE-ROADTRIP-PANEL-005: the corridor width is a choice, and the current one is marked', () => {
const c = corridor()
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
const five = screen.getByRole('button', { name: '5 km' })
expect(five).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: '10 km' }))
expect(c.setWidthKm).toHaveBeenCalledWith(10)
})
it('FE-ROADTRIP-PANEL-006: the day picker only appears once there is more than one drive', () => {
const one = corridor()
const { unmount } = wrap(<RoadtripCorridorPanel corridor={one} routes={routes([day(1, 1)])} />)
expect(screen.queryByText('Day 1')).not.toBeInTheDocument()
unmount()
wrap(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1), day(2, 2)])} />)
expect(screen.getByText('Day 1')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-007: groups the hits by kind and counts each group', () => {
const c = corridor({ categories: ['fuel', 'campsite'] }, {
results: [
poi({ osm_id: 'a', name: 'Aral', alongKm: 10 }),
poi({ osm_id: 'b', name: 'Shell', alongKm: 20 }),
poi({ osm_id: 'c', name: 'Camping Elbe', category: 'campsite', alongKm: 30 }),
],
})
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
// "Fuel" also names an option in the kind picker; the group header is the one
// inside a <header>.
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('header')).find(Boolean)!
expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument()
expect(screen.getByText('Camping Elbe')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-008: each hit says how far off the route and how far along it is', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', offRouteKm: 1.2, alongKm: 40 })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
const row = screen.getByText('Aral').closest('li')!
expect(within(row).getByText(/1\.2 km/)).toBeInTheDocument()
expect(within(row).getByText(/40 km/)).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-009: a hit right at the start says so instead of "0 km along"', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', alongKm: 0.1 })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText('Aral').closest('li')!.textContent).not.toContain('0 km along')
})
it('FE-ROADTRIP-PANEL-010: a chain gets its category icon, never its own logo', () => {
// A corridor is mostly chains, and their marks turned a list of petrol stations into
// an advertisement — while a brand with no logo on file fell back to a different
// picture, so no two rows looked alike. The category's icon is also what the rail and
// the map draw for the same stop.
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', brand_wikidata: 'Q565734' } as Partial<CorridorPoi> & { osm_id: string; name: string })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
const row = screen.getByText('Aral').closest('li')!
expect(row.querySelector('img')).toBeNull()
expect(row.querySelector('svg.lucide-fuel')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-012: adding a hit puts it on the day that was searched', () => {
const onAddPoi = vi.fn()
const c = corridor({}, { results: [poi({ osm_id: 'node:9', name: 'Aral' })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={onAddPoi} />)
fireEvent.click(screen.getByRole('button', { name: /add/i }))
// Day and position both: a fuel stop belongs where it is driven past, not at the end.
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1, 1)
})
it('FE-ROADTRIP-PANEL-023: the position offered is the one worked out for that very hit', () => {
const onAddPoi = vi.fn()
const hit = poi({ osm_id: 'node:9', name: 'Aral', alongKm: 120 })
const insertIndexFor = vi.fn(() => 3)
const c = corridor({ insertIndexFor }, { results: [hit] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={onAddPoi} />)
fireEvent.click(screen.getByRole('button', { name: /add/i }))
expect(insertIndexFor).toHaveBeenCalledWith(hit)
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1, 3)
})
it('FE-ROADTRIP-PANEL-013: while searching it reports progress rather than sitting still', () => {
const c = corridor({}, { loading: true, progress: { done: 3, total: 12 } })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25')
expect(screen.getByRole('button', { name: /3/ })).toBeDisabled()
})
it('FE-ROADTRIP-PANEL-014: a partial answer says which stretches nobody looked at', () => {
const c = corridor({}, { failedAreas: 2, results: [poi({ osm_id: 'a', name: 'Aral' })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
// The difference between "no fuel here" and "nobody looked here".
expect(screen.getByText('2 stretches could not be searched — the place search did not answer.')).toBeInTheDocument()
expect(screen.getByText('Aral')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-015: a total outage reads as a failure, not as an empty road', () => {
const c = corridor({}, { error: true, failedAreas: 4 })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText('The place search is not answering right now.')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-016: a drive longer than the budget admits the tail went unsearched', () => {
const c = corridor({}, { capped: true })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText('The route is long — only the first stretch was searched.')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is admitted, not passed off as empty', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })], truncatedAreas: 2 })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText(/2 stretches had more than fits in one answer/)).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => {
const empty = corridor()
const { unmount } = wrap(<RoadtripCorridorPanel corridor={empty} routes={routes([day(1, 1)])} />)
expect(screen.queryByLabelText('Filter by name')).not.toBeInTheDocument()
unmount()
const found = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
wrap(<RoadtripCorridorPanel corridor={found} routes={routes([day(1, 1)])} />)
expect(screen.getByLabelText('Filter by name')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-019: typing in the filter reports the term rather than searching again', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
fireEvent.change(screen.getByLabelText('Filter by name'), { target: { value: 'shell' } })
expect(c.setNameFilter).toHaveBeenCalledWith('shell')
// The filter must never trigger another round of Overpass requests.
expect(c.search.search).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-PANEL-020: the list shows what is visible, and counts it against the whole', () => {
const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
const c = corridor({ nameFilter: 'shell', visible: [hits[1]] }, { results: hits })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText('Shell')).toBeInTheDocument()
expect(screen.queryByText('Aral')).not.toBeInTheDocument()
expect(screen.getByText('1 of 2 on the way')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-021: filtering everything away says so instead of looking unsearched', () => {
const c = corridor(
{ nameFilter: 'esso', visible: [] },
{ results: [poi({ osm_id: 'a', name: 'Aral' })] },
)
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText(/Nothing on the way matches/)).toBeInTheDocument()
expect(screen.queryByText('Pick what you need and search.')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-022: without permission to add, no hit offers an Add button', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.queryByText('Add')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,555 @@
import React, { useMemo } from 'react'
import {
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin,
} from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import EmptyState from '../shared/EmptyState'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import CustomSelect from '../shared/CustomSelect'
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
import { serviceColor } from './roadtripModel'
import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds'
import { FS } from './typeScale'
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
import type { CorridorPoi } from './useCorridorPois'
import type { RoadtripRoutes } from './useRoadtripRoutes'
interface RoadtripCorridorPanelProps {
corridor: RoadtripCorridor
routes: RoadtripRoutes
/** Opens the place form prefilled from a POI, on a day and at a position in it. */
onAddPoi?: (
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string },
dayId?: number | null,
position?: number | null,
) => void
}
/**
* How each socket family is written on the plug.
*
* Not translated and not derived from the tag: these are proper nouns with an accepted
* spelling, and "Type 2" as a lowercase tag key is not what anybody looks for on a
* charger. Anything not listed falls through to the raw key rather than being hidden,
* because an unknown socket is still worth seeing.
*/
/** The steps worth offering: a household socket, a fast AC post, and the two DC tiers. */
const KW_STEPS = [11, 22, 50, 150]
const SOCKET_LABEL: Record<string, string> = {
type2: 'Type 2',
type2_combo: 'CCS',
type2_cable: 'Type 2 cable',
ccs: 'CCS',
chademo: 'CHAdeMO',
type1: 'Type 1',
type1_combo: 'CCS1',
schuko: 'Schuko',
tesla_supercharger: 'Supercharger',
tesla_destination: 'Tesla Destination',
}
/** Label and icon per category, from the one table every road-trip surface reads. */
const CATEGORY_META = CORRIDOR_CATEGORY_BY_KEY
/** The small capitalised word over a group of controls, matching the rail's own captions. */
const EYEBROW = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint'
/** A card in this column: the same corner, hairline and surface the rail's cards use. */
const CARD = 'rounded-2xl border border-edge-faint bg-surface-card'
/**
* The tile in front of a result, in its category's own colour.
*
* Never the brand's logo. A corridor is mostly chains, and putting their marks here made
* a list of petrol stations read as an advertisement — while the ones with no logo on
* file fell back to a different picture entirely, so no two rows looked alike. The
* category's icon in its own tint is the same thing the rail draws on its dashed line and
* the map draws on the route, which is what makes a row, a pin and a stop recognisably
* one place.
*/
function ResultBadge({ category }: { category: string }): React.ReactElement {
const Icon = CATEGORY_META[category]?.Icon ?? MapPin
const color = serviceColor(category)
return (
<span
className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-[10px]"
// theme-lint-disable — the road-signage palette in `roadtripModel`, the same colour
// this kind of stop carries in the rail and on the map.
style={{ background: `${color}1f`, color }}
>
<Icon size={14} strokeWidth={1.9} aria-hidden />
</span>
)
}
function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
return (
<li className="group flex items-center gap-3 rounded-xl py-1.5 pe-1.5 ps-1 transition-colors hover:bg-surface-hover">
<ResultBadge category={poi.category} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
{poi.name}
</div>
{/* Two facts, two elements. Both are translated phrases rather than short fixed
values, so they stay text and the gap does the separating a middot used to —
which also lets them wrap onto their own lines instead of breaking around a
dot left stranded at the end of a line. */}
<div
className="flex flex-wrap items-baseline gap-x-2 tabular-nums text-content-faint"
style={{ fontSize: FS.meta }}
>
<span>{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}</span>
<span>
{poi.alongKm < 0.5
? t('roadtrip.poi.atStart')
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
</span>
{/* What the charger offers, where OSM says. Socket names are proper nouns and
stay as they are; the numbers around them are what decides whether a car can
use it at all. A station that says nothing shows nothing rather than a row
of dashes, because "not stated" is not "no". */}
{poi.charging?.sockets.length ? (
<span className="flex flex-wrap items-baseline gap-x-1.5">
{poi.charging.sockets.slice(0, 3).map(s => (
<span key={s.type} className="text-content-muted">
{SOCKET_LABEL[s.type] ?? s.type}
{s.kw ? ` ${s.kw} kW` : ''}
{s.count && s.count > 1 ? ` ×${s.count}` : ''}
</span>
))}
</span>
) : null}
{poi.charging?.fee === false ? (
<span className="text-success">{t('roadtrip.poi.free')}</span>
) : null}
</div>
</div>
{/* Always there, quiet until the row is under the pointer: a button that only
exists on hover is one a keyboard user has to find by faith. */}
{onAdd ? (() => {
// One button, two meanings. Somewhere to sleep ends the day rather than
// interrupting the drive, so the icon and the label say that before the dialog
// opens instead of after.
const night = poi.category === 'hotel'
const label = night ? t('roadtrip.stay.nightAction') : t('roadtrip.poi.add')
const Icon = night ? BedDouble : Plus
return (
<Tooltip label={label}>
<button
type="button"
onClick={onAdd}
aria-label={label}
className="grid h-[26px] w-[26px] shrink-0 place-items-center rounded-lg text-content-faint transition-colors hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text focus-visible:outline-none"
>
<Icon size={15} strokeWidth={2.2} aria-hidden />
</button>
</Tooltip>
)
})() : null}
</li>
)
}
/** One category's hits, so a mixed search reads as several short lists instead of one long one. */
function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
category: string
pois: CorridorPoi[]
dayId: number | null
insertIndexFor: RoadtripCorridor['insertIndexFor']
onAddPoi: RoadtripCorridorPanelProps['onAddPoi']
}): React.ReactElement {
const { t } = useTranslation()
const meta = CATEGORY_META[category]
const Icon = meta?.Icon ?? MapPin
const color = serviceColor(category)
return (
<section className="pb-1">
<header className="flex items-center gap-2.5 px-2 pb-1.5 pt-3">
<span
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
// theme-lint-disable — see ResultBadge.
style={{ background: color, color: '#fff' }} // theme-lint-disable — road-signage palette
>
<Icon size={12} strokeWidth={2} aria-hidden />
</span>
<span className="font-semibold tracking-[-0.01em] text-content" style={{ fontSize: FS.name }}>
{meta ? t(meta.labelKey) : category}
</span>
<span className={`ms-auto ${EYEBROW} tabular-nums`} style={{ fontSize: FS.micro }}>
{t('roadtrip.poi.found', { count: pois.length })}
</span>
</header>
<ul>
{pois.map(poi => (
<ResultRow
key={poi.osm_id}
poi={poi}
// Added where it will be driven past, not at the end of the day.
onAdd={onAddPoi ? () => onAddPoi(poi, dayId, insertIndexFor(poi)) : undefined}
/>
))}
</ul>
</section>
)
}
/**
* "What is on the way" — the right column while road trip mode is on.
*
* Searching is a button, not a side effect of panning: one run is several Overpass
* requests and the mirrors are shared infrastructure. The scope is one day's drive, which
* keeps a run to a handful of boxes and makes "add this" unambiguous — the stop lands on
* the day whose route it was found along. Hits are drawn on the map at the same time, so
* the list answers "which of these" and the map answers "which side of the road".
*
* Three cards down one column, the same shapes the rail opposite uses: what to look for,
* how the search is getting on, and what it found. The day it searches sits in the
* header rather than among the filters — it is the question's subject, not one of its
* conditions.
*/
export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const { search } = corridor
const dayOptions = routes.days.map(d => ({
value: String(d.dayId),
label: t('roadtrip.day', { number: d.dayNumber }),
}))
const grouped = CORRIDOR_CATEGORY_KEYS
.map(key => ({ key, pois: corridor.visible.filter(p => p.category === key) }))
.filter(g => g.pois.length > 0)
// Something was found, the filter just hides it — a different state from "not searched
// yet" and from "the drive really has none of these".
/**
* The socket families this search actually turned up, in the order the table lists
* them. Offering all ten would put CHAdeMO in the dropdown for a corridor that has
* three Type 2 posts on it, which is a filter that can only produce an empty list.
*/
const socketsFound = useMemo(() => {
const seen = new Set<string>()
for (const p of search.results) for (const s of p.charging?.sockets ?? []) seen.add(s.type)
// Compared explicitly: the default sort is by UTF-16 code unit, which is not the
// order anybody reads a list of plug names in.
return [...seen].sort((a, b) => a.localeCompare(b))
}, [search.results])
const hasCharging = socketsFound.length > 0
const filteredToNothing = search.results.length > 0 && corridor.visible.length === 0
// The day has to have routed. Until it does, the corridor is built from the
// straight line between the stops, so the boxes march across whatever lies
// between them rather than along the roads actually driven — and when the
// routing answer lands a second later the line changes, which clears the
// search mid-flight and drops every result with no error and no explanation.
// The routing runs one day at a time, about a second apart, so on a long trip
// that window is wide open for the day somebody picks.
const dayRouted = (corridor.day?.geometry.length ?? 0) > 1
const canSearch = !search.loading
&& !routes.loading
&& dayRouted
&& corridor.categories.length > 0
&& (corridor.day?.stops.length ?? 0) > 1
const progressPct = search.progress.total
? Math.round((search.progress.done / search.progress.total) * 100)
: 0
const warnings: [string, string][] = []
if (search.capped) warnings.push(['capped', t('roadtrip.poi.capped')])
if (search.error) warnings.push(['failed', t('roadtrip.poi.failed')])
// Some boxes answered and some did not. Saying so is the difference between "there is
// no fuel on this stretch" and "nobody looked at this stretch".
else if (search.failedAreas > 0) warnings.push(['partial', t('roadtrip.poi.partial', { count: search.failedAreas })])
// The stretch was searched and the answer came back short. Without this line a filter
// finding nothing looks like proof there is nothing.
if (search.truncatedAreas > 0) warnings.push(['truncated', t('roadtrip.poi.truncated', { count: search.truncatedAreas })])
return (
<div className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
{/* Header — the panel's name, and the day the question is about. */}
<div className="flex flex-shrink-0 items-center gap-2.5 px-1">
<h2 className="font-semibold tracking-[-0.022em] text-content" style={{ fontSize: FS.panelTitle }}>
{t('roadtrip.poi.title')}
</h2>
{dayOptions.length > 1 ? (
<div className="ms-auto min-w-0">
<CustomSelect
value={corridor.dayId}
onChange={value => corridor.setDayId(String(value))}
options={dayOptions}
size="sm"
/>
</div>
) : null}
</div>
{/* What to look for. */}
<div className={`flex flex-shrink-0 flex-col gap-3.5 ${CARD} px-4 pb-3.5 pt-3.5`}>
<div className="flex flex-col gap-2">
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.looking')}</span>
<RoadtripCategoryPicker
keys={CORRIDOR_CATEGORY_KEYS}
meta={CATEGORY_META}
selected={corridor.categories}
onToggle={corridor.toggleCategory}
/>
</div>
<span className="h-px bg-edge-faint" aria-hidden />
<div className="flex flex-col gap-2">
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.within')}</span>
<div className="flex gap-1 rounded-xl bg-surface-tertiary p-1">
{CORRIDOR_WIDTHS_KM.map(km => (
<button
key={km}
type="button"
aria-pressed={corridor.widthKm === km}
onClick={() => corridor.setWidthKm(km)}
style={{ fontSize: FS.control }}
className={`flex h-[26px] flex-1 items-center justify-center rounded-lg tabular-nums transition-colors ${
corridor.widthKm === km
? 'bg-surface-card font-semibold text-content shadow-card'
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
}`}
>
{formatDistance(km, distanceUnit)}
</button>
))}
</div>
</div>
{/* One word, so it can be read at the size a primary action deserves. The panel
it sits in is headed "along the route" and every control above it narrows the
same search; repeating that on the button only made it small. */}
<button
type="button"
onClick={search.search}
disabled={!canSearch}
className="flex h-[38px] w-full items-center justify-center gap-2 rounded-xl bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
>
{search.loading
? <RotateCw size={15} className="animate-spin" aria-hidden />
: <Search size={15} strokeWidth={2} aria-hidden />}
{search.loading
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
: t('roadtrip.poi.search')}
</button>
</div>
{/* How the run is getting on — a row of its own while it lasts, with the share
done as a figure rather than only as a bar: a corridor search is many requests
and "58%" answers "is this worth waiting for" that a bar only hints at. */}
{search.loading ? (
<div
className="flex h-[32px] flex-shrink-0 items-center gap-2 rounded-xl border border-edge bg-surface-card pe-1.5 ps-3 text-content-muted"
role="progressbar"
aria-valuenow={progressPct}
aria-valuemin={0}
aria-valuemax={100}
>
<RotateCw size={13} className="animate-spin" aria-hidden />
<span className="min-w-0 truncate font-medium" style={{ fontSize: FS.control }}>
{t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })}
</span>
<span
className="ms-auto inline-flex h-[22px] shrink-0 items-center rounded-full bg-inverse px-2 font-geist font-semibold tabular-nums tracking-[0.08em] text-inverse-text"
style={{ fontSize: FS.micro }}
>
{`${progressPct}%`}
</span>
</div>
) : null}
{warnings.length > 0 ? (
<div className="flex flex-shrink-0 flex-col gap-1.5">
{warnings.map(([key, text]) => (
<p
key={key}
className={`flex items-start gap-1.5 ${key === 'failed' ? 'text-danger' : 'text-warning'}`}
style={{ fontSize: FS.meta }}
>
<AlertTriangle size={12} className="mt-px shrink-0" aria-hidden />
{text}
</p>
))}
</div>
) : null}
{/* What was found. */}
<div className={`flex min-h-0 flex-1 flex-col overflow-hidden ${CARD}`}>
{/* Narrowing what was found. Deliberately not called "search": the button above
asks the place search for new results, this only hides rows already in. It
appears once there is something to narrow. */}
{search.results.length > 0 ? (
<div className="flex-shrink-0 border-b border-edge-faint p-3">
<div className="relative">
<Search size={14} strokeWidth={1.9} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden />
<input
type="text"
value={corridor.nameFilter}
onChange={e => corridor.setNameFilter(e.target.value)}
placeholder={t('roadtrip.poi.filter')}
aria-label={t('roadtrip.poi.filter')}
style={{ fontSize: FS.time }}
className="h-[32px] w-full rounded-[10px] bg-surface-tertiary pe-8 ps-9 text-content placeholder:text-content-faint focus:outline-none focus:ring-1 focus:ring-accent"
/>
{corridor.nameFilter ? (
<button
type="button"
onClick={() => corridor.setNameFilter('')}
aria-label={t('common.clear')}
className="absolute end-1.5 top-1/2 -translate-y-1/2 rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
>
<X size={13} aria-hidden />
</button>
) : null}
</div>
{/* Around one point of the drive, which is how a break is actually planned: you
decide roughly where to stop and then look at what is there, rather than
reading seventy hits spread over seven hundred kilometres. The midpoint of
a leg is offered alongside the stops, because a break on a five-hour drive
belongs in the middle of it and not at either end.
The minWidth: 0 is what makes the name shorten instead of pushing the
kilometre control off the panel: a nowrap label reports its whole width as
its min-content contribution, so a flex item at its default min-width of
auto refuses to go below it however long the place name is. */}
{corridor.anchors.length > 1 && corridor.day ? (
<div className="mt-2 flex gap-2">
<CustomSelect
value={corridor.section ? `${corridor.section.kind}:${corridor.section.index}` : ''}
onChange={value => {
const raw = String(value)
if (!raw) { corridor.setSection(null); return }
const [kind, index] = raw.split(':')
corridor.setSection({
dayId: corridor.day!.dayId,
kind: kind as 'stop' | 'leg',
index: Number(index),
})
}}
options={[
{ value: '', label: t('roadtrip.poi.wholeDay') },
...corridor.anchors.map(a => ({
value: `${a.kind}:${a.index}`,
label: a.kind === 'stop'
? (corridor.day!.stops[a.index]?.name ?? t('roadtrip.poi.wholeDay'))
: t('roadtrip.poi.midLeg', {
from: corridor.day!.stops[a.index]?.name ?? '',
to: corridor.day!.stops[a.index + 1]?.name ?? '',
}),
})),
]}
style={{ flex: 1, minWidth: 0 }}
size="sm"
menuFit="content"
/>
{corridor.section ? (
<CustomSelect
value={String(corridor.sectionKm)}
onChange={value => corridor.setSectionKm(Number(value))}
options={CORRIDOR_SECTION_KM.map(km => ({
value: String(km),
label: `± ${formatDistance(km, distanceUnit)}`,
}))}
style={{ width: 110, flexShrink: 0 }}
size="sm"
/>
) : null}
</div>
) : null}
{/* Only where there is a charger to narrow. Two answers OSM actually carries
often enough to filter on: which plug, and how fast. A station that states
neither stays in the list, because roughly two thirds of them state no
power at all and reading that silence as "too slow" would empty the map. */}
{hasCharging ? (
<div className="mt-2 flex gap-2">
<CustomSelect
value={corridor.socketFilter}
onChange={value => corridor.setSocketFilter(String(value))}
options={[
{ value: '', label: t('roadtrip.poi.anySocket') },
...socketsFound.map(s => ({ value: s, label: SOCKET_LABEL[s] ?? s })),
]}
style={{ flex: 1, minWidth: 0 }}
size="sm"
/>
<CustomSelect
value={String(corridor.minKw)}
onChange={value => corridor.setMinKw(Number(value))}
options={[
{ value: '0', label: t('roadtrip.poi.anyPower') },
...KW_STEPS.map(kw => ({ value: String(kw), label: `${kw}+ kW` })),
]}
style={{ flex: 1, minWidth: 0 }}
size="sm"
/>
</div>
) : null}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 pb-3">
{grouped.length === 0 ? (
// The same empty state the rest of TREK uses, with the mascot acting out the
// scene. `search` while it is running, `idle` before anyone has asked, and a
// confused look when a filter left nothing standing — the picture says which
// of the three it is before the sentence is read.
<EmptyState
scene={search.loading ? 'search' : 'idle'}
mood={filteredToNothing ? 'confused' : undefined}
size={88}
compact
surface="var(--bg-secondary)"
title={
search.loading
? t('roadtrip.poi.searchingHint')
: filteredToNothing
// Four controls narrow this list and only one of them is the
// name box. Blaming it regardless produced `Nothing on the
// way matches ""` — empty quotes naming a filter the reader
// never set — when a section, a plug type or a minimum power
// was what emptied it.
? corridor.nameFilter.trim()
? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() })
: t('roadtrip.poi.noneMatchFilters')
: t('roadtrip.poi.empty')
}
/>
) : (
<>
{/* Shown whenever anything narrowed the list, not only the name
box: a section or a plug filter hides hits just as much, and
the reader is owed the same "x of y" either way. */}
{corridor.visible.length !== search.results.length ? (
<p className="px-2 pt-2.5 text-content-faint" style={{ fontSize: FS.meta }}>
{t('roadtrip.poi.foundFiltered', { count: corridor.visible.length, total: search.results.length })}
</p>
) : null}
{grouped.map(g => (
<ResultGroup
key={g.key}
category={g.key}
pois={g.pois}
dayId={corridor.day?.dayId ?? null}
insertIndexFor={corridor.insertIndexFor}
onAddPoi={onAddPoi}
/>
))}
</>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,130 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import RoadtripLimitsCard from './RoadtripLimitsCard'
/**
* FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning.
*
* The one that matters is when a number is written. These fields had no local
* state, so every keystroke was a settings PUT: typing "180" sent three, and
* they raced. The value that decides whether a day is over budget could end up a
* tenth of what was typed and only say so after the next reload.
*/
function open(onSave?: (key: string, value: number) => void) {
render(<RoadtripLimitsCard onSave={onSave} />)
fireEvent.click(screen.getByRole('button'))
}
/** The three inputs, in the order the dialog lists them. */
const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
beforeEach(() => {
useSettingsStore.setState({
settings: {
roadtrip_leg_minutes: 0,
roadtrip_day_minutes: 0,
roadtrip_range_km: 0,
distance_unit: 'metric',
} as never,
})
})
describe('RoadtripLimitsCard', () => {
it('FE-ROADTRIP-LIMITS-001: typing a number writes nothing until the field is left', () => {
const onSave = vi.fn()
open(onSave)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '1' } })
fireEvent.change(leg, { target: { value: '18' } })
fireEvent.change(leg, { target: { value: '180' } })
// Three keystrokes used to be three unordered PUTs carrying 1, 18 and 180.
expect(onSave).not.toHaveBeenCalled()
expect(leg.value).toBe('180')
})
it('FE-ROADTRIP-LIMITS-002: leaving the field saves once, with what was typed', () => {
const onSave = vi.fn()
open(onSave)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '180' } })
fireEvent.blur(leg)
expect(onSave).toHaveBeenCalledTimes(1)
expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 180)
})
it('FE-ROADTRIP-LIMITS-003: Enter commits without having to click away', () => {
const onSave = vi.fn()
open(onSave)
const [, day] = inputs()
fireEvent.change(day, { target: { value: '540' } })
fireEvent.keyDown(day, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('roadtrip_day_minutes', 540)
})
it('FE-ROADTRIP-LIMITS-004: leaving a field nobody touched saves nothing', () => {
const onSave = vi.fn()
open(onSave)
fireEvent.blur(inputs()[0])
expect(onSave).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-LIMITS-005: retyping the value it already had is not a write', () => {
useSettingsStore.setState({
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
})
const onSave = vi.fn()
open(onSave)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '180' } })
fireEvent.blur(leg)
expect(onSave).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-LIMITS-006: clearing a field turns the limit off', () => {
useSettingsStore.setState({
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
})
const onSave = vi.fn()
open(onSave)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '' } })
fireEvent.blur(leg)
expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 0)
})
it('FE-ROADTRIP-LIMITS-007: the range field carries the reader own unit', () => {
useSettingsStore.setState({
settings: { roadtrip_leg_minutes: 0, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'imperial' } as never,
})
open(vi.fn())
expect(screen.getByText('mi')).toBeInTheDocument()
expect(screen.queryByText('km')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-LIMITS-008: without a way to save, the dialog is read-only', () => {
// `onSave` absent is how the caller says the reader may look but not change.
open(undefined)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '180' } })
// Nothing to assert but the absence of a crash: the commit path has no
// handler to call, and must not assume one.
expect(() => fireEvent.blur(leg)).not.toThrow()
})
})
@@ -0,0 +1,201 @@
import React, { useState } from 'react'
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { convertDistance, formatDistance } from '../../utils/units'
import { formatDurationShort } from './roadtripModel'
import type { DistanceUnit } from '../../types'
import { FS } from './typeScale'
/**
* The three numbers that decide when the rail speaks up about the driving.
*
* Behind a button rather than laid out in the column, because this is set once and then
* read never: a permanent card costs three rows of a narrow sidebar for something most
* travellers touch on the first day of planning and leave alone after. The button carries
* the current answer, so what is set stays visible without the form being.
*
* All three are personal rather than instance configuration: how long somebody is willing
* to sit behind a wheel, and how far their car goes on a tank, belong to the traveller.
* They are plain per-user settings, which is why this needs no migration.
*
* Nothing here suggests a stop, adds one, or knows anything about a vehicle. OSM has no
* idea what car this is, and neither has TREK, so the range is a number the traveller
* types. Furkot does exactly the same, for the same reason.
*/
/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */
function parseLimit(raw: string): number {
const n = Number(raw.replace(',', '.'))
return Number.isFinite(n) && n > 0 ? n : 0
}
function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
icon: typeof Clock
label: string
suffix: string
value: number | undefined
placeholder: string
onChange: (next: number) => void
}): React.ReactElement {
// Typed here, saved on blur or Enter. Writing on every keystroke sent one
// settings PUT per character — typing "180" produced three, carrying 1, 18 and
// 180, unordered and concurrent over HTTP/2. Whichever the server committed
// last won, so the limit that decides every over-budget warning could quietly
// end up a tenth of what was typed, and only show it after the next reload.
const [draft, setDraft] = useState<string | null>(null)
const shown = draft ?? (value ? String(value) : '')
const commit = () => {
if (draft === null) return
const next = parseLimit(draft)
setDraft(null)
if (next !== (value ?? 0)) onChange(next)
}
return (
<label className="flex items-center gap-3">
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
<input
type="number"
inputMode="numeric"
min={0}
value={shown}
placeholder={placeholder}
onChange={e => setDraft(e.target.value)}
onBlur={commit}
// Committed here rather than by blurring and letting onBlur do it: a
// second commit is a no-op anyway, and going through blur made Enter
// depend on focus handling instead of on the key.
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
className="w-20 rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none"
/>
<span className="w-7 text-caption text-content-faint">{suffix}</span>
</label>
)
}
export default function RoadtripLimitsCard({ onSave }: {
/** Persists one setting. Absent leaves the dialog read-only. */
onSave?: (key: string, value: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const settings = useSettingsStore(s => s.settings)
const [open, setOpen] = useState(false)
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes
const rangeKm = settings.roadtrip_range_km
// Kilometres in storage, the traveller's own unit on screen. Without the round trip an
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
// third too early for ever after.
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined
const setRange = (shown: number) => {
const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown
onSave?.('roadtrip_range_km', Math.round(km))
}
// What is set, on the button itself, so the form does not have to be open to read it.
// Each one keeps its own icon, because three numbers in a row say nothing about which
// is which: a clock, a calendar and a pump do. Distances go through formatDistance, so
// an imperial traveller reads miles here and types miles in the dialog, while what is
// stored stays kilometres either way.
const badges = [
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
return (
<>
{/* Full width and card-sized, the same weight as the search controls above it: this
is one of three things this column does, not a footnote under the other two.
The current answer rides on the button, so what is set stays visible without the
form being open. */}
<button
type="button"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{/* No icon tile on the trigger: the three badges below already carry a clock, a
calendar and a pump, and a fourth mark in front of the title only ate the width
they need to sit on one line. */}
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-body font-semibold text-content">
{t('roadtrip.limit.title')}
</span>
{badges.length ? (
<span className="flex flex-wrap items-center gap-1">
{badges.map(({ key, Icon, text }) => (
<span
key={key}
className="inline-flex h-[16px] items-stretch overflow-hidden rounded border border-edge"
>
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint">
<Icon size={9} aria-hidden />
</span>
<span
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
style={{ fontSize: FS.label }}
>
{text}
</span>
</span>
))}
</span>
) : (
<span className="truncate text-content-faint" style={{ fontSize: FS.label }}>
{t('roadtrip.limit.none')}
</span>
)}
</span>
<ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />
</button>
{open ? (
<Modal isOpen onClose={() => setOpen(false)} size="sm" title={
<span className="flex items-center gap-2">
<SlidersHorizontal size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.limit.title')}
</span>
}>
<div className="flex flex-col gap-4">
<LimitRow
icon={Clock}
label={t('roadtrip.limit.legLabel')}
suffix={t('roadtrip.limit.minutes')}
value={legMinutes}
placeholder={t('roadtrip.limit.off')}
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
/>
<LimitRow
icon={CalendarClock}
label={t('roadtrip.limit.dayLabel')}
suffix={t('roadtrip.limit.minutes')}
value={dayMinutes}
placeholder={t('roadtrip.limit.off')}
onChange={v => onSave?.('roadtrip_day_minutes', v)}
/>
<LimitRow
icon={Fuel}
label={t('roadtrip.limit.rangeLabel')}
suffix={distanceUnit === 'imperial' ? 'mi' : 'km'}
value={rangeShown}
placeholder={t('roadtrip.limit.off')}
onChange={setRange}
/>
{/* Said out loud rather than left to be discovered: the day figure this is
measured against is driving time only. It does not include how long anyone
stands still, and it does not include the leg to the hotel, because the
rail does not model one. A limit called "travel time per day" over that
number would be a lie that looks tidy. */}
<p className="text-caption text-content-faint">{t('roadtrip.limit.hint')}</p>
</div>
</Modal>
) : null}
</>
)
}
@@ -0,0 +1,52 @@
import React from 'react'
import { CalendarDays, Route } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
interface RoadtripModeSwitchProps {
active: boolean
onChange: (roadtrip: boolean) => void
}
/**
* Switches the plan view's left rail between the day plan and the road trip reading of
* the same trip. Only rendered while the road trip addon is on, so a trip that isn't
* driven anywhere never grows a control it has no use for.
*/
export default function RoadtripModeSwitch({ active, onChange }: RoadtripModeSwitchProps): React.ReactElement {
const { t } = useTranslation()
const options: [boolean, string, typeof Route][] = [
[false, t('roadtrip.mode.days'), CalendarDays],
[true, t('roadtrip.mode.roadtrip'), Route],
]
return (
<div
role="tablist"
aria-label={t('roadtrip.mode.label')}
className="mx-3.5 mb-0 mt-3 flex gap-1 rounded-xl border border-edge-faint bg-surface-tertiary p-1"
>
{options.map(([value, label, Icon]) => {
const selected = active === value
return (
<button
key={label}
type="button"
role="tab"
aria-selected={selected}
onClick={() => onChange(value)}
// Sized off the day plan's own type, like the road trip rail below it: the
// switcher sits above both, so it cannot be the largest thing in the column.
style={{ fontSize: 'calc(11.5px * var(--fs-scale-body, 1))' }}
className={`flex h-[30px] flex-1 items-center justify-center gap-1.5 rounded-lg px-2 transition-colors ${
selected
? 'bg-surface-card font-semibold text-content shadow-card'
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
}`}
>
<Icon size={13} strokeWidth={1.8} aria-hidden />
<span className="truncate">{label}</span>
</button>
)
})}
</div>
)
}
@@ -0,0 +1,559 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, within, fireEvent } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import RoadtripSidebar from './RoadtripSidebar'
import RoadtripModeSwitch from './RoadtripModeSwitch'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { RouteSegment } from '../../types'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
return {
placeId: over.assignmentId * 10,
lat: 53.5,
lng: 9.9,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
}
}
const leg = (over: Partial<RouteSegment> = {}): RouteSegment =>
({ distance: 100000, duration: 3600, distanceText: '100 km', durationText: '1 h', mode: 'driving', ...over }) as RouteSegment
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
const stops = over.stops ?? [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Berlin' }),
]
return {
dayId: 1,
dayNumber: 1,
date: null,
title: null,
stops,
legs: [leg()],
legVias: [], driveWarnings: [], dayWarning: null,
schedule: { entries: stops.map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
geometry: [],
distance: 100000,
duration: 3600,
...over,
}
}
function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
return {
days: [day()],
lines: [],
segments: [],
accessLines: [],
vias: [],
totalDistance: 100000,
totalDuration: 3600,
totalStops: 2,
quietDays: [],
loading: false,
...over,
}
}
describe('RoadtripSidebar', () => {
/**
* The value the summary head pairs with this label. The same words also name the chips
* further down for screen readers, so the lookup has to hold on to the `dt` — otherwise
* it matches "Distance" three times over.
*/
const total = (label: string): string => {
// The summary is three columns; the caption and its number are siblings in one cell.
// The same words also name the badges further down, so the lookup keeps to the cell
// whose first child is the caption itself.
const caption = screen.getAllByText(label).find(el => el.parentElement?.firstElementChild === el)!
return caption.parentElement!.lastElementChild!.textContent!.replace(/\s+/g, ' ').trim()
}
it('FE-ROADTRIP-SIDEBAR-001: leads with the totals for the whole drive', () => {
wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
expect(total('Distance')).toBe('250 km')
expect(total('Driving time')).toBe('2 h 30 min')
expect(total('Stops')).toBe('5')
})
it('FE-ROADTRIP-SIDEBAR-002: chains the stops with the drive between them', () => {
// Trip and day totals deliberately differ from the single leg, so the leg's own two
// values are the only place '100 km' and '1 h' can come from.
wrap(<RoadtripSidebar routes={routes({
days: [day({ distance: 250000, duration: 9000 })],
totalDistance: 250000,
totalDuration: 9000,
})} />)
expect(screen.getByText('Hamburg')).toBeInTheDocument()
expect(screen.getByText('Berlin')).toBeInTheDocument()
// One sentence, not two glued values: the middot is gone and so is the pair of chips.
expect(screen.getByText('100 km in 1 h')).toBeInTheDocument()
expect(screen.queryByText('100 km · 1 h')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-003: says a leg is still coming rather than showing a blank', () => {
wrap(<RoadtripSidebar routes={routes({
days: [day({ legs: [undefined], distance: 0, duration: 0 })],
totalDistance: 0,
totalDuration: 0,
})} />)
// An absent value says so rather than reading zero.
expect(screen.queryByText(/100 km in/)).not.toBeInTheDocument()
expect(screen.getByText('No route')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-004: sets a pinned arrival apart from a computed one', () => {
const stops = [stop({ assignmentId: 1, name: 'Ferry' }), stop({ assignmentId: 2, name: 'Berlin' })]
const schedule = {
entries: [
{ arrival: '09:00', departure: '09:30', anchored: true, dayOffset: 0 },
{ arrival: '10:30', departure: '10:30', anchored: false, dayOffset: 0 },
],
warnings: [],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
// Both are plain text at the row's edge — no pill, no icon. What separates the time
// somebody chose from the one the drive worked out is weight and ink. The reason is
// named in a tooltip, which only enters the DOM on hover, so the visible difference
// is what this pins.
const pinned = screen.getByText('09:00')
const computed = screen.getByText('10:30')
expect(pinned.className).toContain('font-semibold')
expect(computed.className).not.toContain('font-semibold')
})
it('FE-ROADTRIP-SIDEBAR-005: flags a stop the drive cannot reach in time', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
const schedule = {
entries: stops.map(() => ({ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 })),
warnings: [{ index: 1, code: 'late' as const, minutes: 45 }],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-006: shows how long a stop is planned to take', () => {
const stops = [stop({ assignmentId: 1, name: 'Museum', dwellMinutes: 90 }), stop({ assignmentId: 2, name: 'Berlin' })]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-007: selecting a stop reports the place and the assignment', () => {
const onSelectStop = vi.fn()
wrap(<RoadtripSidebar routes={routes()} onSelectStop={onSelectStop} />)
fireEvent.click(screen.getByText('Berlin'))
expect(onSelectStop).toHaveBeenCalledWith(20, 2)
})
it('FE-ROADTRIP-SIDEBAR-008: marks the selected stop for assistive tech', () => {
wrap(<RoadtripSidebar routes={routes()} selectedAssignmentId={2} />)
const selected = screen.getByText('Berlin').closest('button')!
expect(selected).toHaveAttribute('aria-current', 'true')
expect(screen.getByText('Hamburg').closest('button')).not.toHaveAttribute('aria-current')
})
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive explains itself', () => {
wrap(<RoadtripSidebar routes={routes({ days: [], totalDistance: 0, totalDuration: 0, totalStops: 0 })} />)
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
expect(screen.queryByText('Hamburg')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-010: each day carries its own distance and time', () => {
const second = day({ dayId: 2, dayNumber: 2, distance: 50000, duration: 1800, stops: [
stop({ assignmentId: 3, name: 'Dresden' }),
stop({ assignmentId: 4, name: 'Prague' }),
] })
wrap(<RoadtripSidebar routes={routes({ days: [day(), second], totalStops: 4 })} />)
const headings = screen.getAllByRole('heading', { level: 3 })
expect(headings).toHaveLength(2)
expect(within(headings[1].parentElement!).getByText(/50 km/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-011: draws the midnight crossing where it happens', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
const schedule = {
entries: [
{ arrival: '22:00', departure: '22:00', anchored: false, dayOffset: 0 },
{ arrival: '01:30', departure: '01:30', anchored: false, dayOffset: 1 },
],
warnings: [{ index: 1, code: 'overnight' as const }],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
// Said twice on purpose: once as the break in the chain, once on the arrival after
// it, where "01:30" would otherwise read as tonight.
expect(screen.getAllByText('Next day')).toHaveLength(2)
// The day marker rides on the arrival that crossed midnight, where "01:30" would
// otherwise read as tonight.
expect(screen.getByText('01:30').textContent).toContain('+1')
})
it('FE-ROADTRIP-SIDEBAR-012: a late arrival past midnight keeps both findings', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
const schedule = {
entries: [
{ arrival: '22:00', departure: '22:00', anchored: false, dayOffset: 0 },
{ arrival: '01:30', departure: '01:30', anchored: true, dayOffset: 1 },
],
// Both belong to the same stop. Reading only the first one dropped the late flag.
warnings: [
{ index: 1, code: 'overnight' as const },
{ index: 1, code: 'late' as const, minutes: 45 },
],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
expect(screen.getAllByText('Next day')).toHaveLength(2)
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-013: says the totals are still a partial sum while legs arrive', () => {
const { unmount } = wrap(<RoadtripSidebar routes={routes({ loading: true })} />)
expect(screen.getByText('Still working out the rest of the drive')).toBeInTheDocument()
unmount()
wrap(<RoadtripSidebar routes={routes({ loading: false })} />)
expect(screen.queryByText('Still working out the rest of the drive')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-014: shows the name a day was given', () => {
wrap(<RoadtripSidebar routes={routes({ days: [day({ title: 'Along the coast' })] })} />)
expect(screen.getByText('Along the coast')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-015: dates the day machine-readably, whatever the locale prints', () => {
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ date: '2026-08-31' })] })} />)
const stamp = container.querySelector('time')!
expect(stamp).toHaveAttribute('datetime', '2026-08-31')
expect(stamp.textContent).not.toBe('')
})
it('FE-ROADTRIP-SIDEBAR-016: a leg a plugin routed carries its own mark and its note', () => {
const charged = leg({ mode: 'plugin:charge', noteText: '25 min charge' })
wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [charged] })] })} />)
// Free text, so it gets a line rather than being squeezed into a pill.
expect(screen.getByText('25 min charge')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-019: a fuel stop breaks the drive instead of taking a number', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }),
stop({ assignmentId: 3, name: 'Berlin' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} />)
// The kind is what you look for on a drive; the running number is on the map pin.
expect(screen.getByLabelText('Fuel')).toBeInTheDocument()
expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
// How long the pause takes is what the row is for.
expect(screen.getByText('15 min')).toBeInTheDocument()
// The two places the trip is actually for keep counting one, two — the tank stop
// between them is part of the drive, not a third destination. Scoped to the chain:
// the summary above counts stops too, in the same digits.
const chain = screen.getByRole('list')
expect(within(chain).getByText('1')).toBeInTheDocument()
expect(within(chain).getByText('2')).toBeInTheDocument()
expect(within(chain).queryByText('3')).not.toBeInTheDocument()
// Two drive bands, because the stop splits the leg it falls on. Scoped to the chain
// again: the day's header badge phrases its own total the same way.
expect(within(chain).getAllByText('100 km in 1 h')).toHaveLength(2)
})
it('FE-ROADTRIP-SIDEBAR-032: every stop offers a stay, whether it has one or not', () => {
// The value has never been editable anywhere in TREK, so a stop without one needs a
// way in before it can get one — the plus sits in the slot the number will occupy.
const onEditStay = vi.fn()
const stops = [
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Berlin' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onEditStay={onEditStay} />)
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
const chain = screen.getByRole('list')
const add = within(chain).getByText('+')
fireEvent.click(add)
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
})
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Berlin' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
// A label, not an invitation that leads nowhere.
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
expect(screen.queryByText('+')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-030: a day counts the places it visits, not the pauses on the way', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Ionity Horst', stopType: 'charging' }),
stop({ assignmentId: 3, name: 'Rasthof Fläming', stopType: 'rest_area' }),
stop({ assignmentId: 4, name: 'Berlin' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg(), leg()] })] })} />)
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-031: every kind the corridor finds is a pause, not a destination', () => {
// Anything picked off "along the route" was come across on the way, so all six kinds
// sit inside the leg with their own icon and none of them counts as a stop.
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Camping Seeblick', stopType: 'campsite' }),
stop({ assignmentId: 3, name: 'Bäckerei Junge', stopType: 'restaurant' }),
stop({ assignmentId: 4, name: 'Holstentor', stopType: 'sights' }),
stop({ assignmentId: 5, name: 'Lübeck' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg(), leg(), leg()] })] })} />)
expect(screen.getByLabelText('Campsite')).toBeInTheDocument()
expect(screen.getByLabelText('Food')).toBeInTheDocument()
expect(screen.getByLabelText('Sights')).toBeInTheDocument()
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-020: a stop kind nobody knows falls back to the running number', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Etwas Neues', stopType: 'helipad' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
expect(within(screen.getByRole('list')).getByText('2')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-021: without the right to edit days the chain cannot be dragged', () => {
const { container } = wrap(<RoadtripSidebar routes={routes()} />)
expect(container.querySelector('li[draggable="true"]')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-022: dropping a stop on another reports where it should go', () => {
const onReorderStop = vi.fn()
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Lueneburg' }),
stop({ assignmentId: 3, name: 'Berlin' }),
]
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} onReorderStop={onReorderStop} />,
)
const rows = container.querySelectorAll('li[draggable="true"]')
expect(rows).toHaveLength(3)
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
fireEvent.dragStart(rows[2], { dataTransfer })
fireEvent.dragOver(rows[0])
fireEvent.drop(rows[0])
// The last stop dropped on the first: day, the assignment moved, and its new index.
expect(onReorderStop).toHaveBeenCalledWith(1, 3, 0)
})
it('FE-ROADTRIP-SIDEBAR-023: dropping a stop back on itself changes nothing', () => {
const onReorderStop = vi.fn()
const { container } = wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
const rows = container.querySelectorAll('li[draggable="true"]')
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
fireEvent.dragStart(rows[0], { dataTransfer })
fireEvent.dragOver(rows[0])
fireEvent.drop(rows[0])
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-024: the chain can be reordered from the keyboard too', () => {
const onReorderStop = vi.fn()
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Lueneburg' }),
stop({ assignmentId: 3, name: 'Berlin' }),
]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} onReorderStop={onReorderStop} />)
// Dragging is a gesture; without this the chain is unreachable without a mouse.
fireEvent.keyDown(screen.getByText('Lueneburg').closest('button')!, { key: 'ArrowDown', altKey: true })
expect(onReorderStop).toHaveBeenCalledWith(1, 2, 2)
fireEvent.keyDown(screen.getByText('Lueneburg').closest('button')!, { key: 'ArrowUp', altKey: true })
expect(onReorderStop).toHaveBeenCalledWith(1, 2, 0)
})
it('FE-ROADTRIP-SIDEBAR-025: the ends of a day cannot be pushed past themselves', () => {
const onReorderStop = vi.fn()
wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
fireEvent.keyDown(screen.getByText('Hamburg').closest('button')!, { key: 'ArrowUp', altKey: true })
fireEvent.keyDown(screen.getByText('Berlin').closest('button')!, { key: 'ArrowDown', altKey: true })
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-026: an arrow without Alt still belongs to the list, not to us', () => {
const onReorderStop = vi.fn()
wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
fireEvent.keyDown(screen.getByText('Hamburg').closest('button')!, { key: 'ArrowDown' })
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-027: a day without a drive appears only while something is dragged', () => {
const quiet = { dayId: 9, dayNumber: 2, date: null, title: null, stops: [] }
const onMoveStopToDay = vi.fn()
const { container } = wrap(
<RoadtripSidebar
routes={routes({ quietDays: [quiet] })}
onReorderStop={vi.fn()}
onMoveStopToDay={onMoveStopToDay}
/>,
)
// Nothing in flight: an empty day is not worth a row, the rail is about the drive.
expect(screen.queryByText(/drop one here/i)).not.toBeInTheDocument()
const rows = container.querySelectorAll('li[draggable="true"]')
fireEvent.dragStart(rows[0], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
expect(screen.getByText(/drop one here/i)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-028: dropping on another day reports both days', () => {
const quiet = { dayId: 9, dayNumber: 2, date: null, title: null, stops: [] }
const onMoveStopToDay = vi.fn()
const { container } = wrap(
<RoadtripSidebar
routes={routes({ quietDays: [quiet] })}
onReorderStop={vi.fn()}
onMoveStopToDay={onMoveStopToDay}
/>,
)
const rows = container.querySelectorAll('li[draggable="true"]')
fireEvent.dragStart(rows[1], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
const target = screen.getByText(/drop one here/i).closest('section')!
fireEvent.dragOver(target)
fireEvent.drop(target)
// From day 1, the second stop (assignment 2), onto day 9, at its end.
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 2, 9, 0)
})
it('FE-ROADTRIP-SIDEBAR-029: a stop dropped on a row of another day lands at that row', () => {
const second = day({
dayId: 2,
dayNumber: 2,
stops: [stop({ assignmentId: 3, name: 'Dresden' }), stop({ assignmentId: 4, name: 'Prague' })],
})
const onMoveStopToDay = vi.fn()
const onReorderStop = vi.fn()
const { container } = wrap(
<RoadtripSidebar
routes={routes({ days: [day(), second], totalStops: 4 })}
onReorderStop={onReorderStop}
onMoveStopToDay={onMoveStopToDay}
/>,
)
const rows = container.querySelectorAll('li[draggable="true"]')
fireEvent.dragStart(rows[0], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
fireEvent.dragOver(rows[3])
fireEvent.drop(rows[3])
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 1, 2, 1)
// Crossing days is a different call; the same-day reorder must not also fire.
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car', () => {
const ferry = leg({ mode: 'ferry' })
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />)
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
const schedule = {
entries: stops.map(() => ({ arrival: '09:00', departure: '09:00', anchored: false, dayOffset: 0 })),
warnings: [{ index: 1, code: 'late' as const }],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
expect(screen.getByLabelText(/0 min/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-018: the track badge is a viewer-free control, and it says when a day is shaped', () => {
const onFollowTrack = vi.fn()
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
const { rerender } = wrap(
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} viaCounts={{ 1: 4 }} />,
)
const badge = screen.getByRole('button', { name: 'Track' })
fireEvent.click(badge)
expect(onFollowTrack).toHaveBeenCalledWith(1)
// The rail draws no vias, so the tint is the only place a drive shaped by hand
// differs from one the router picked on its own.
expect(badge.className).toContain('bg-accent-subtle')
rerender(
<TranslationProvider>
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} />
</TranslationProvider>,
)
expect(screen.getByRole('button', { name: 'Track' }).className).not.toContain('bg-accent-subtle')
})
it('FE-ROADTRIP-SIDEBAR-019: a viewer is offered no track badge at all', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
expect(screen.queryByRole('button', { name: 'Track' })).toBeNull()
})
})
describe('RoadtripModeSwitch', () => {
it('FE-ROADTRIP-MODESWITCH-001: offers both readings and marks the active one', () => {
wrap(<RoadtripModeSwitch active onChange={vi.fn()} />)
const tabs = screen.getAllByRole('tab')
expect(tabs).toHaveLength(2)
expect(tabs[0]).toHaveAttribute('aria-selected', 'false')
expect(tabs[1]).toHaveAttribute('aria-selected', 'true')
})
it('FE-ROADTRIP-MODESWITCH-002: switching back to the day plan reports false', () => {
const onChange = vi.fn()
wrap(<RoadtripModeSwitch active onChange={onChange} />)
fireEvent.click(screen.getAllByRole('tab')[0])
expect(onChange).toHaveBeenCalledWith(false)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import RoadtripStayModal, { type StayDraft } from './RoadtripStayModal'
/**
* FE-STAYMODAL-001..011 — how long the traveller stays at one stop.
*
* The value is `places.duration_minutes`, which the schedule has read for years
* and nothing could write. Two things are worth pinning: that clearing sends a
* zero rather than a null — the update statement folds a null into "leave it
* alone", so a null could give a stop a stay but never take one away — and that
* the dialog answers the question the number is chosen for, which is when the
* drive leaves again.
*/
const draft = (over: Partial<StayDraft> = {}): StayDraft => ({
placeId: 9,
name: 'Rasthof Dammer Berge',
minutes: null,
arrival: '12:00',
...over,
})
function open(over: Partial<StayDraft> = {}, onSave = vi.fn(), onClose = vi.fn()) {
render(<RoadtripStayModal stop={draft(over)} onClose={onClose} onSave={onSave} />)
return { onSave, onClose }
}
const slider = () => screen.getByRole('slider') as HTMLInputElement
beforeEach(() => {
useSettingsStore.setState({ settings: { time_format: '24h' } as never })
})
describe('RoadtripStayModal', () => {
it('FE-STAYMODAL-001: no stop means no dialog', () => {
const { container } = render(<RoadtripStayModal stop={null} onClose={vi.fn()} onSave={vi.fn()} />)
expect(container).toBeEmptyDOMElement()
})
it('FE-STAYMODAL-002: it opens on the stop own length, not on the last one looked at', () => {
open({ minutes: 45 })
expect(slider().value).toBe('45')
})
it('FE-STAYMODAL-003: a stop with no stay opens at nothing rather than at a guess', () => {
open({ minutes: null })
expect(slider().value).toBe('0')
expect(screen.getAllByText(/no stay|none/i).length).toBeGreaterThan(0)
})
it('FE-STAYMODAL-004: it answers the question the number is chosen for', () => {
// The arrival is fixed by the drive; the departure is the only thing this
// dialog moves, and seeing it move is the point of a slider over a box.
open({ minutes: 30, arrival: '12:00' })
expect(screen.getByText('12:00')).toBeInTheDocument()
expect(screen.getByText('12:30')).toBeInTheDocument()
fireEvent.change(slider(), { target: { value: '90' } })
expect(screen.getByText('13:30')).toBeInTheDocument()
})
it('FE-STAYMODAL-005: a stop the drive has not timed shows no clock at all', () => {
// Better than inventing one: a departure without an arrival is a number with
// nothing behind it.
open({ minutes: 30, arrival: null })
expect(screen.queryByText(/\d\d:\d\d/)).not.toBeInTheDocument()
})
it('FE-STAYMODAL-006: the clock follows the reader own format', () => {
useSettingsStore.setState({ settings: { time_format: '12h' } as never })
open({ minutes: 60, arrival: '13:00' })
expect(screen.getByText(/1:00\s*PM/i)).toBeInTheDocument()
expect(screen.getByText(/2:00\s*PM/i)).toBeInTheDocument()
})
it('FE-STAYMODAL-007: the two buttons nudge, and stop at the ends', () => {
open({ minutes: 0 })
// Nothing below zero: the minus is off rather than silently doing nothing.
expect(screen.getByRole('button', { name: '-5' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '+5' }))
expect(slider().value).toBe('5')
fireEvent.click(screen.getByRole('button', { name: '-5' }))
expect(slider().value).toBe('0')
})
it('FE-STAYMODAL-008: the presets are one tap, which is the point of them', () => {
open({ minutes: 0 })
fireEvent.click(screen.getByRole('button', { name: '1 h' }))
expect(slider().value).toBe('60')
})
it('FE-STAYMODAL-009: saving reports the stop and the chosen length, then closes', async () => {
const { onSave, onClose } = open({ minutes: 0 })
fireEvent.change(slider(), { target: { value: '45' } })
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith(9, 45))
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('FE-STAYMODAL-010: clearing sends a zero, never a null', async () => {
// The update statement folds a null into "leave it alone", so a null could
// give a stop a stay and never take one away.
const { onSave } = open({ minutes: 90 })
fireEvent.click(screen.getByRole('button', { name: /no stay|none/i }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith(9, 0))
expect(onSave.mock.calls[0][1]).not.toBeNull()
})
it('FE-STAYMODAL-011: a save already running is not started twice', async () => {
// Two clicks on Save wrote the same value twice and closed on the first
// answer, which on a slow connection is easy to do.
let release: () => void = () => {}
const onSave = vi.fn(() => new Promise<void>(r => { release = r }))
open({ minutes: 30 }, onSave as never)
const save = screen.getByRole('button', { name: /^save$/i })
fireEvent.click(save)
fireEvent.click(save)
expect(onSave).toHaveBeenCalledTimes(1)
release()
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1))
})
it('FE-STAYMODAL-012: cancelling changes nothing', () => {
const { onSave, onClose } = open({ minutes: 30 })
fireEvent.change(slider(), { target: { value: '120' } })
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
expect(onSave).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
})
@@ -0,0 +1,219 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDurationShort, formatClock, parseClock } from './roadtripModel'
import { formatClockTime } from '../../utils/formatters'
/**
* The lengths a stop usually takes, so the common answer is one tap.
*
* Kept short on purpose: a longer list reads as a form, and anything not on it is what
* the slider is for.
*/
const PRESETS = [15, 30, 45, 60, 90, 120]
/** As far as the slider goes, in minutes. Beyond four hours it is a day, not a stop. */
const MAX = 240
/** The step the slider and the two buttons move in. */
const STEP = 5
/** The stop being given a length. */
export interface StayDraft {
placeId: number
name: string
minutes: number | null
/** When the drive gets here, so the dialog can show what the stay pushes back. */
arrival?: string | null
}
interface RoadtripStayModalProps {
stop: StayDraft | null
onClose: () => void
/** Zero clears it: the rail reads "no stay" and "nothing planned" as the same thing. */
onSave: (placeId: number, minutes: number) => Promise<void> | void
}
/** The slider's own track and thumb, drawn from tokens so both themes get it right. */
const SLIDER = [
'h-1.5 w-full cursor-pointer appearance-none rounded-full bg-surface-tertiary',
'[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:shadow-card',
'[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[var(--bg-card)]',
'[&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:border-0',
'[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent',
].join(' ')
/**
* How long the traveller stays at one stop.
*
* A road-trip idea rather than a property of the place: it is what pushes every later
* arrival back, so it belongs beside the drive times rather than in the place form, which
* is why this opens from the rail and nowhere else.
*
* Built around the number instead of around a field. The chosen length is the largest
* thing on screen and updates as the slider moves, and under it the dialog answers the
* question the number is actually being chosen for: given when the drive arrives, when
* does it leave again. Typing minutes into a box could never show that.
*
* The value is `places.duration_minutes`, which the schedule has read for years and
* nothing has ever been able to write. Cleared as a zero rather than a null, because the
* update statement folds a null into "leave it alone" — a null could give a stop a stay
* but never take one away.
*/
export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripStayModalProps): React.ReactElement | null {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [minutes, setMinutes] = useState(0)
const [saving, setSaving] = useState(false)
// Reopened on a different stop, so it starts from that stop's own value rather than
// from whatever the last one was left on.
useEffect(() => { setMinutes(stop?.minutes ?? 0) }, [stop])
// What the stay does to the day, worked out live: the arrival is fixed by the drive,
// the departure is the only thing this dialog moves.
const times = useMemo(() => {
const arrival = parseClock(stop?.arrival)
if (arrival === null) return null
return {
arrive: formatClockTime(formatClock(arrival), is12h),
leave: formatClockTime(formatClock(arrival + minutes), is12h),
}
}, [stop?.arrival, minutes, is12h])
if (!stop) return null
const commit = async (value: number) => {
if (saving) return
setSaving(true)
try {
await onSave(stop.placeId, value)
onClose()
} finally {
setSaving(false)
}
}
const nudge = (delta: number) => setMinutes(m => Math.min(MAX, Math.max(0, m + delta)))
return (
<Modal isOpen onClose={onClose} size="sm" title={
<span className="flex items-center gap-2">
<Hourglass size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.stop.stay')}
</span>
}>
<div className="flex flex-col gap-5">
<p className="break-words text-center text-caption text-content-muted">{stop.name}</p>
{/* The number, the way a timer shows one. Minus and plus flank it so a value can
be nudged without hunting for the slider's thumb. */}
<div className="flex items-center justify-center gap-4">
<button
type="button"
onClick={() => nudge(-STEP)}
disabled={minutes === 0}
aria-label={`-${STEP}`}
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
>
<Minus size={15} aria-hidden />
</button>
<span
aria-live="polite"
className="min-w-[8rem] text-center text-title font-semibold tabular-nums tracking-tight text-content"
>
{minutes > 0 ? formatDurationShort(minutes * 60) : t('roadtrip.stay.none')}
</span>
<button
type="button"
onClick={() => nudge(STEP)}
disabled={minutes >= MAX}
aria-label={`+${STEP}`}
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
>
<Plus size={15} aria-hidden />
</button>
</div>
<input
type="range"
min={0}
max={MAX}
step={STEP}
value={minutes}
onChange={e => setMinutes(Number(e.target.value))}
aria-label={t('roadtrip.stay.custom')}
className={SLIDER}
/>
{/* What the stay costs the rest of the day. The arrival cannot move — the drive
decides it — so the arrow shows the one end this dialog does move. */}
{times ? (
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.arrive')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{times.arrive}</span>
</span>
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.leave')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">{times.leave}</span>
</span>
</div>
) : null}
<div className="flex flex-wrap justify-center gap-1.5">
{PRESETS.map(value => (
<button
key={value}
type="button"
onClick={() => setMinutes(value)}
className={`rounded-full border px-3 py-1 text-caption font-medium tabular-nums transition-colors ${
minutes === value
? 'border-accent bg-accent text-accent-text'
: 'border-edge bg-surface-card text-content-secondary hover:border-content-faint'
}`}
>
{formatDurationShort(value * 60)}
</button>
))}
</div>
</div>
<div className="mt-6 flex items-center gap-2">
{/* Removing is the same write as setting one, so it sits with the other actions
rather than behind a confirmation. */}
<button
type="button"
disabled={saving || !stop.minutes}
onClick={() => void commit(0)}
className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
>
{t('roadtrip.stay.none')}
</button>
<button
type="button"
onClick={onClose}
className="ms-auto rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
{t('common.cancel')}
</button>
<button
type="button"
disabled={saving}
onClick={() => void commit(minutes)}
className="rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text transition-opacity disabled:opacity-40"
>
{t('common.save')}
</button>
</div>
</Modal>
)
}
@@ -0,0 +1,277 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import RoadtripStopPopup, { type RoadtripStopDraft } from './RoadtripStopPopup'
import type { CorridorPoi } from './useCorridorPois'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
function poi(over: Partial<CorridorPoi> = {}): CorridorPoi {
return {
osm_id: 'node:9',
name: 'Aral Autohof',
lat: 53.1,
lng: 10.2,
category: 'fuel',
poi_type: 'amenity=fuel',
address: null,
website: null,
phone: null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap',
offRouteKm: 1.2,
alongKm: 120,
...over,
} as CorridorPoi
}
const draft = (over: Partial<RoadtripStopDraft> = {}): RoadtripStopDraft => ({
poi: poi(),
dayId: 4,
position: 2,
dayNumber: 1,
...over,
})
const noop = {
onClose: vi.fn(),
onSave: vi.fn(),
onMoreDetails: vi.fn(),
}
describe('RoadtripStopPopup', () => {
it('FE-ROADTRIP-STOPPOPUP-001: without a draft there is nothing to show', () => {
const { container } = wrap(<RoadtripStopPopup draft={null} {...noop} />)
expect(container).toBeEmptyDOMElement()
})
it('FE-ROADTRIP-STOPPOPUP-002: says where the stop will land, counting from one', () => {
wrap(<RoadtripStopPopup draft={draft({ position: 2, dayNumber: 3 })} {...noop} />)
expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
// Position 2 in the array is the third stop for a reader.
expect(screen.getByText('Day 3, as stop 3')).toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-003: the kind the search found is preselected', () => {
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: /Charging/ })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-ROADTRIP-STOPPOPUP-004: saving reports the kind and how long it takes', async () => {
const onSave = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
// Fuel suggests ten minutes; nobody plans an hour for it.
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 10 }))
})
it('FE-ROADTRIP-STOPPOPUP-005: picking another kind brings its own duration along', async () => {
const onSave = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
// Charging is half an hour, not the ten minutes a tank takes.
fireEvent.click(screen.getByRole('button', { name: /Charging/ }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'charging', dwellMinutes: 30 }))
})
it('FE-ROADTRIP-STOPPOPUP-006: a duration chosen by hand survives the save', async () => {
const onSave = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
fireEvent.click(screen.getByRole('button', { name: /45 min/ }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
})
it('FE-ROADTRIP-STOPPOPUP-007: unpicking the kind leaves an ordinary place', async () => {
const onSave = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
fireEvent.click(screen.getByRole('button', { name: /Fuel/ }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: null, dwellMinutes: 10 }))
})
/**
* Every category the corridor can search for, with the name its button carries.
*
* The keys are the contract with `CATEGORY_OSM_FILTERS` on the server, which labels
* each hit with the category its own OSM tag matched. A key the popup does not know
* preselects nothing, and the stop is saved as an ordinary place — which is exactly
* how food and sights used to end up as numbered destinations in the middle of a drive.
*/
const CORRIDOR_KINDS: [string, string][] = [
['fuel', 'Fuel'],
['charging', 'Charging'],
['rest_area', 'Rest area'],
['campsite', 'Campsite'],
['restaurant', 'Food'],
['sights', 'Sights'],
]
it.each(CORRIDOR_KINDS)('FE-ROADTRIP-STOPPOPUP-008: a %s hit preselects its own kind', (category, label) => {
const { unmount } = wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category, name: 'Found on the way' }) })} {...noop} />)
expect(screen.getByRole('button', { name: new RegExp(label) })).toHaveAttribute('aria-pressed', 'true')
// Every other kind is off, so the preselect cannot be reading the wrong row. Scoped
// to the kind buttons: the dwell presets use `aria-pressed` for their own state.
for (const [, other] of CORRIDOR_KINDS) {
if (other === label) continue
expect(screen.getByRole('button', { name: new RegExp(other) })).toHaveAttribute('aria-pressed', 'false')
}
unmount()
})
it('FE-ROADTRIP-STOPPOPUP-012: a kind nobody knows preselects nothing', () => {
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'museum', name: 'Albertinum' }) })} {...noop} />)
expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: /Campsite/ })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-ROADTRIP-STOPPOPUP-009: warns when the same place is already on the trip', () => {
const { unmount } = wrap(<RoadtripStopPopup draft={draft()} {...noop} duplicateName="Aral Autohof" />)
expect(screen.getByText(/already on this trip/)).toBeInTheDocument()
unmount()
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
expect(screen.queryByText(/already on this trip/)).not.toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-010: the way out to the full form is offered and reported', () => {
const onMoreDetails = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onMoreDetails={onMoreDetails} />)
fireEvent.click(screen.getByRole('button', { name: 'More details' }))
expect(onMoreDetails).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-STOPPOPUP-011: a save in flight cannot be fired twice', async () => {
let release: () => void = () => {}
const onSave = vi.fn(() => new Promise<void>(resolve => { release = resolve }))
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
const add = screen.getByRole('button', { name: 'Add' })
fireEvent.click(add)
await waitFor(() => expect(add).toBeDisabled())
fireEvent.click(add)
expect(onSave).toHaveBeenCalledTimes(1)
// Settle it inside act, or React re-enables the button after the test has finished.
await act(async () => { release() })
expect(add).not.toBeDisabled()
})
// ── Overnight mode ─────────────────────────────────────────────────────────
//
// Somewhere to sleep is the one hit that ends the day rather than interrupting the
// drive, so the dialog grows a second mode instead of offering a hotel a dwell of
// thirty minutes.
const overnight = {
days: [
{ id: 4, number: 1, date: '2026-08-31' },
{ id: 5, number: 2, date: '2026-09-01' },
{ id: 6, number: 3, date: '2026-09-02' },
],
defaultEndDayId: 5,
}
const nightDraft = (category: string) => draft({
poi: poi({ category, name: 'Hotel Adlon' }),
overnight,
})
it('FE-ROADTRIP-STOPPOPUP-020: a hotel opens on the overnight mode', () => {
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSaveNight={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Overnight' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('Check out on')).toBeInTheDocument()
// The two questions a pause asks are gone: a hotel has no kind and no dwell.
expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-021: a campsite opens on the pause mode but offers the night', () => {
// It is a service stop today and stays one, but people do sleep at campsites, so the
// choice is offered rather than decided.
wrap(<RoadtripStopPopup draft={nightDraft('campsite')} {...noop} onSaveNight={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Pause' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Overnight' })).toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-022: a petrol station is never offered a night', () => {
// Nothing to choose between, so no switch at all.
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSaveNight={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Overnight' })).not.toBeInTheDocument()
})
it('FE-ROADTRIP-STOPPOPUP-023: booking a night reports the check-out day, defaulting to the next', () => {
const onSaveNight = vi.fn()
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSaveNight={onSaveNight} />)
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '', checkOut: '' })
})
it('FE-ROADTRIP-STOPPOPUP-024: switching back to a pause saves a pause', () => {
const onSave = vi.fn()
const onSaveNight = vi.fn()
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSave={onSave} onSaveNight={onSaveNight} />)
fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSaveNight).not.toHaveBeenCalled()
expect(onSave).toHaveBeenCalled()
})
it('FE-ROADTRIP-STOPPOPUP-026: a scheme-less OSM website still leaves the app', () => {
// OSM `website` tags are routinely written without a scheme. As a raw href
// `www.hotel.de` is a RELATIVE url, so the click navigated the planner to
// /trips/<id>/www.hotel.de and the user lost the page they were on.
wrap(
<RoadtripStopPopup
draft={draft({ poi: poi({ category: 'hotel', website: 'www.hotel-adlon.de' }), overnight })}
{...noop}
onSaveNight={vi.fn()}
/>,
)
expect(screen.getByRole('link', { name: 'Website' })).toHaveAttribute('href', 'https://www.hotel-adlon.de')
})
it('FE-ROADTRIP-STOPPOPUP-027: a website that is not a link is not rendered as one', () => {
// Anything that is not http(s) loses the link rather than being handed to
// the browser. The CSP stops a javascript: navigation on a stock deployment,
// but this is defended everywhere else in the app and belongs here too.
for (const bad of ['javascript:alert(1)', 'data:text/html,<script>', 'tel:+49301234']) {
const { unmount } = wrap(
<RoadtripStopPopup
draft={draft({ poi: poi({ category: 'hotel', website: bad }), overnight })}
{...noop}
onSaveNight={vi.fn()}
/>,
)
expect(screen.queryByRole('link', { name: 'Website' }), bad).not.toBeInTheDocument()
unmount()
}
})
it('FE-ROADTRIP-STOPPOPUP-025: without a way to book a night the switch stays away', () => {
// An onSaveNight-less caller gets the dialog it always had, rather than a mode that
// leads nowhere.
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} />)
expect(screen.queryByRole('button', { name: 'Overnight' })).not.toBeInTheDocument()
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
})
})
@@ -0,0 +1,326 @@
import React, { useState } from 'react'
import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { useTranslation } from '../../i18n/TranslationContext'
import { safeExternalHref } from '../../utils/safeUrl'
import { formatDate } from '../../utils/formatters'
import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
import type { CorridorPoi } from './useCorridorPois'
import type { RoadtripStopType } from '@trek/shared'
/**
* The four kinds of stop, with the icon and colour they already carry in the corridor
* search, and how long each one usually takes.
*
* Deliberately not TREK's place categories: those are the traveller's own editable list,
* shared across the whole instance. Refuelling is not a taste, it is a fact about the
* place, so it lives in `places.stop_type` and keeps the palette the search gave it.
*
* The colours are the road-trip palette from `poiCategories.ts` and are the same in both
* themes on purpose — a brand-neutral fuel blue reads as fuel either way.
*/
// The kinds, their icons, their colours and how long each one usually takes all come
// from the one table in stopKinds.ts.
/** How long to stand still, offered as the few answers anyone actually gives. */
const DWELL_CHOICES = [5, 10, 20, 30, 45, 60]
export interface RoadtripStopDraft {
poi: CorridorPoi
dayId: number
/** Where in the day's chain it goes, worked out from how far along the drive it sits. */
position: number
dayNumber: number
/**
* Set only for a hit somebody could sleep at, and it is what turns the popup into two
* modes rather than one. Absent, the dialog asks the two questions it always asked.
*/
overnight?: {
/** The trip's days from this one on, in travel order — the check-out options. */
days: { id: number; number: number; date: string | null }[]
/** The day after this one, or this one when it is the last. */
defaultEndDayId: number
}
}
interface RoadtripStopPopupProps {
draft: RoadtripStopDraft | null
/** Names of stops already on this trip that came from the same OSM object. */
duplicateName?: string | null
onClose: () => void
onSave: (input: { stopType: RoadtripStopType | null; dwellMinutes: number }) => Promise<void> | void
/**
* Books the hit as a night instead of a pause: the place, its day, and a row in
* day_accommodations, in one go. Only ever called when the draft carries `overnight`.
*/
onSaveNight?: (input: { endDayId: number; checkIn: string; checkOut: string }) => Promise<void> | void
/** Hands over what has been picked here, so the full form opens on the same answer. */
onMoreDetails: (stop: { stopType: RoadtripStopType | null; dwellMinutes: number }) => void
}
/**
* Adding something found along the drive, without the full place form.
*
* The form is right for a place you are planning a day around; it is wrong for a petrol
* station. Everything it asks for — category, price, photo, notes, files — is empty for a
* refuelling stop, and in road trip mode it also drops the place into the unplanned pool,
* which neither column shows. This asks the two questions that matter (what kind of stop,
* how long) and says where it will land, with a way out to the full form for the rest.
*/
export default function RoadtripStopPopup({
draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails,
}: RoadtripStopPopupProps): React.ReactElement | null {
const { t, language } = useTranslation()
// Through the same guard every other external link in the app goes through.
// This value is an unvalidated OpenStreetMap tag, and OSM `website` tags are
// routinely written without a scheme — `www.hotel.de` as a raw href is a
// RELATIVE url, so the click navigated the planner to
// /trips/<id>/www.hotel.de instead of leaving the app. The helper adds the
// scheme for a bare host and drops anything that is not http(s).
const websiteHref = safeExternalHref(draft?.poi.website)
const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
const [stopType, setStopType] = useState<RoadtripStopType | null>(suggested?.key ?? null)
const [dwell, setDwell] = useState<number>(suggested?.defaultMinutes ?? 30)
// A hotel is a night by default and a campsite a pause, which is what each already
// means everywhere else — but both offer the other, because a campsite is somewhere
// people sleep and a hotel is somewhere people stop for lunch.
const [night, setNight] = useState<boolean>(draft?.poi.category === 'hotel')
const [endDayId, setEndDayId] = useState<number | null>(draft?.overnight?.defaultEndDayId ?? null)
const [checkIn, setCheckIn] = useState('')
const [checkOut, setCheckOut] = useState('')
const [saving, setSaving] = useState(false)
if (!draft) return null
const overnight = draft.overnight
const asNight = !!overnight && !!onSaveNight && night
const kind = STOP_KINDS.find(k => k.key === stopType)
const submit = async (): Promise<void> => {
setSaving(true)
try {
if (asNight) {
await onSaveNight?.({
endDayId: endDayId ?? overnight!.defaultEndDayId,
checkIn,
checkOut,
})
} else {
await onSave({ stopType, dwellMinutes: dwell })
}
} finally {
setSaving(false)
}
}
return (
<Modal isOpen onClose={onClose} title={t('roadtrip.stop.addTitle')} size="sm">
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<span
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
// theme-lint-disable — the road-trip palette, the same one the map pin and the
// result row use; a token here would make the three disagree.
style={{ background: `${kind?.color ?? '#64748B'}1f`, color: kind?.color ?? '#64748B' }}
>
{kind ? <kind.Icon size={17} aria-hidden /> : <ParkingSquare size={17} aria-hidden />}
</span>
<div className="min-w-0 flex-1">
<p className="break-words text-body font-semibold text-content">{draft.poi.name}</p>
<p className="mt-0.5 text-caption text-content-muted">
{t('roadtrip.stop.landsOn', { day: draft.dayNumber, position: draft.position + 1 })}
</p>
</div>
</div>
{duplicateName ? (
// The full place form warns about duplicates; losing that warning would make
// this the easiest way to add the same petrol station twice.
<p className="flex items-start gap-1.5 rounded-lg bg-warning-soft px-2.5 py-2 text-caption text-warning">
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
{t('roadtrip.stop.duplicate', { name: duplicateName })}
</p>
) : null}
{/* Only where sleeping is on the table. A petrol station gets no choice, because
there is nothing to choose between. */}
{overnight && onSaveNight ? (
<div className="flex rounded-lg border border-edge p-0.5">
{[
{ on: false, label: t('roadtrip.stay.mode.pause'), Icon: Hourglass },
{ on: true, label: t('roadtrip.stay.mode.night'), Icon: BedDouble },
].map(({ on, label, Icon }) => (
<button
key={label}
type="button"
aria-pressed={night === on}
onClick={() => setNight(on)}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-caption transition-colors ${
night === on
? 'bg-accent font-semibold text-accent-text'
: 'text-content-secondary hover:bg-surface-hover'
}`}
>
<Icon size={13} aria-hidden />
{label}
</button>
))}
</div>
) : null}
{asNight ? (
<div className="flex flex-col gap-3">
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stay.checkOutDay')}
</span>
<div className="mt-1.5">
<CustomSelect
value={String(endDayId ?? overnight!.defaultEndDayId)}
onChange={value => setEndDayId(Number(value))}
options={overnight!.days.map(d => ({
value: String(d.id),
// Through the shared helper, like every other day date in
// this feature. Interpolating the column gave this one
// dropdown a bare ISO date while the rail beside it showed
// the same day in the reader's own order.
label: d.date
? `${t('roadtrip.stay.dayNumber', { number: d.number })} · ${formatDate(d.date, language)}`
: t('roadtrip.stay.dayNumber', { number: d.number }),
}))}
size="sm"
/>
</div>
</div>
{/* Both optional: leaving them empty is normal, and the server stores null.
A hotel found on a map has no idea when its reception opens. */}
<div className="flex gap-3">
<label className="min-w-0 flex-1">
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('day.checkIn')}
</span>
<input
type="time"
value={checkIn}
onChange={e => setCheckIn(e.target.value)}
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
/>
</label>
<label className="min-w-0 flex-1">
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('day.checkOut')}
</span>
<input
type="time"
value={checkOut}
onChange={e => setCheckOut(e.target.value)}
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
/>
</label>
</div>
{/* What OSM knows about the house, which is the whole of what we offer: no
price, no availability, no link into a booking portal. TREK links to maps
and to the place's own site, never to a vendor. */}
{websiteHref || draft.poi.phone ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
{websiteHref ? (
<a
href={websiteHref}
target="_blank"
rel="noreferrer noopener"
className="text-accent hover:underline"
>
{t('places.formWebsite')}
</a>
) : null}
{draft.poi.phone ? (
<a href={`tel:${draft.poi.phone}`} className="text-accent hover:underline">
{draft.poi.phone}
</a>
) : null}
</div>
) : null}
</div>
) : (
<>
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stop.kind')}
</span>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{STOP_KINDS.map(({ key, labelKey, Icon }) => {
const on = stopType === key
return (
<button
key={key}
type="button"
aria-pressed={on}
onClick={() => {
setStopType(on ? null : key)
// Picking a kind is also picking how long it takes, until the user
// says otherwise — a charge is not a fuel stop.
if (!on) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes)
}}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption transition-colors ${
on
? 'border-transparent bg-accent font-semibold text-accent-text'
: 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
}`}
>
<Icon size={12} aria-hidden />
{t(labelKey)}
</button>
)
})}
</div>
</div>
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stop.stay')}
</span>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{DWELL_CHOICES.map(minutes => (
<button
key={minutes}
type="button"
aria-pressed={dwell === minutes}
onClick={() => setDwell(minutes)}
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-caption tabular-nums transition-colors ${
dwell === minutes
? 'border-transparent bg-accent font-semibold text-accent-text'
: 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
}`}
>
<Hourglass size={11} aria-hidden />
{formatDurationShort(minutes * 60)}
</button>
))}
</div>
</div>
</>
)}
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
<button
type="button"
onClick={() => onMoreDetails({ stopType, dwellMinutes: dwell })}
className="rounded-lg px-2.5 py-1.5 text-caption text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
{t('roadtrip.stop.moreDetails')}
</button>
<button
type="button"
onClick={submit}
disabled={saving}
className="ms-auto rounded-lg bg-accent px-3.5 py-1.5 text-body font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{t('roadtrip.poi.add')}
</button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,126 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import RoadtripTrackModal from './RoadtripTrackModal'
import type { FollowTrack, TrackChoice } from './useFollowTrack'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
const track = (over: Partial<TrackChoice> & { id: number; name: string }): TrackChoice => ({
color: '#0ea5e9',
points: [{ lat: 52, lng: 13 }, { lat: 52, lng: 14 }],
lengthKm: 68,
gapKm: 0.4,
...over,
})
function follow(over: Partial<FollowTrack> = {}): FollowTrack {
return {
dayId: 7,
open: vi.fn(),
close: vi.fn(),
tracks: [track({ id: 3, name: 'Atlantic Road' })],
busy: false,
round: 0,
error: null,
outcome: null,
apply: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
viaCount: 0,
available: true,
current: null,
namesByDay: {},
...over,
}
}
describe('RoadtripTrackModal', () => {
it('FE-TRACKMODAL-001: closed is nothing at all, not a hidden dialog', () => {
const { container } = wrap(<RoadtripTrackModal follow={follow({ dayId: null })} dayNumber={1} />)
expect(container.firstChild).toBeNull()
})
it('FE-TRACKMODAL-002: a trip with no tracks says where tracks come from', () => {
wrap(<RoadtripTrackModal follow={follow({ tracks: [] })} dayNumber={2} />)
expect(screen.getByText('No tracks in this trip')).toBeTruthy()
expect(screen.getByText(/GPX or KML/)).toBeTruthy()
})
it('FE-TRACKMODAL-003: a track close to the day says so rather than quoting a distance', () => {
wrap(<RoadtripTrackModal follow={follow()} dayNumber={1} />)
expect(screen.getByText(/along this day/)).toBeTruthy()
expect(screen.getByText(/68 km long/)).toBeTruthy()
})
it('FE-TRACKMODAL-004: a track that runs elsewhere is offered with the distance to here', () => {
wrap(<RoadtripTrackModal follow={follow({ tracks: [track({ id: 9, name: 'Elsewhere', gapKm: 240 })] })} dayNumber={1} />)
expect(screen.getByText(/240 km off this day/)).toBeTruthy()
})
it('FE-TRACKMODAL-005: nothing is applied until a track is picked', () => {
const state = follow()
wrap(<RoadtripTrackModal follow={state} dayNumber={1} />)
const action = screen.getByRole('button', { name: 'Follow this track' })
expect((action as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByText('Atlantic Road'))
expect((screen.getByRole('button', { name: 'Follow this track' }) as HTMLButtonElement).disabled).toBe(false)
// What it will do, before it does it: a track becomes a dozen waypoints on the day.
expect(screen.getByText(/via points/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Follow this track' }))
expect(state.apply).toHaveBeenCalledWith(3)
})
it('FE-TRACKMODAL-006: a picked track can be unpicked', () => {
wrap(<RoadtripTrackModal follow={follow()} dayNumber={1} />)
fireEvent.click(screen.getByText('Atlantic Road'))
fireEvent.click(screen.getByText('Atlantic Road'))
expect((screen.getByRole('button', { name: 'Follow this track' }) as HTMLButtonElement).disabled).toBe(true)
})
it('FE-TRACKMODAL-007: a run says which round it is on rather than going blank', () => {
wrap(<RoadtripTrackModal follow={follow({ busy: true, round: 4 })} dayNumber={1} />)
expect(screen.getByRole('status').textContent).toMatch(/round 4/)
})
it('FE-TRACKMODAL-008: a finished run reports how close the drive got', () => {
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 6, strayKm: 0.8, capped: false } })} dayNumber={1} />)
expect(screen.getByRole('status').textContent).toMatch(/6 via points placed/)
})
it('FE-TRACKMODAL-009: a run that ran out of vias admits the gap it left', () => {
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 12, strayKm: 2.4, capped: true } })} dayNumber={1} />)
// The honest form: it did what it could, and the number says how much that was worth.
expect(screen.getByRole('status').textContent).toMatch(/no closer than/)
})
it('FE-TRACKMODAL-010: a drive that already followed the track is not dressed up as work', () => {
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 0, strayKm: 0, capped: false } })} dayNumber={1} />)
expect(screen.getByRole('status').textContent).toMatch(/already followed/)
})
it('FE-TRACKMODAL-011: a failure is an alert, not a silent nothing', () => {
wrap(<RoadtripTrackModal follow={follow({ error: 'route' })} dayNumber={1} />)
expect(screen.getByRole('alert').textContent).toMatch(/routing service/)
})
it('FE-TRACKMODAL-012: dropping the day\'s vias is offered only once it has some', () => {
const state = follow({ viaCount: 5 })
const { rerender } = wrap(<RoadtripTrackModal follow={state} dayNumber={1} />)
const drop = screen.getByRole('button', { name: /Drop 5 via points/ })
fireEvent.click(drop)
expect(state.clear).toHaveBeenCalled()
rerender(<TranslationProvider><RoadtripTrackModal follow={follow({ viaCount: 0 })} dayNumber={1} /></TranslationProvider>)
expect(screen.queryByRole('button', { name: /Drop/ })).toBeNull()
})
it('FE-TRACKMODAL-013: while it runs, nothing else in the dialog can be touched', () => {
wrap(<RoadtripTrackModal follow={follow({ busy: true, viaCount: 2 })} dayNumber={1} />)
expect((screen.getByRole('button', { name: /Atlantic Road/ }) as HTMLButtonElement).disabled).toBe(true)
expect((screen.getByRole('button', { name: /Drop 2 via points/ }) as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -0,0 +1,193 @@
import React, { useEffect, useState } from 'react'
import { AlertTriangle, Check, RotateCw, Spline, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
import EmptyState from '../shared/EmptyState'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import { FS } from './typeScale'
import type { FollowTrack, TrackChoice } from './useFollowTrack'
/**
* Picking the track a day drives along.
*
* The list is the whole dialog. Everything a traveller needs to tell two imported tracks
* apart is on the row — the colour the map draws it in, how long it runs, and how far it
* is from this particular day — and the last of those is why the list is ordered the way
* it is: a trip carrying every track of a three-week tour still opens on the one beside
* today's drive.
*
* Applying is slow and says so. It is a routing round trip per refinement, half a minute
* on a long scenic route, and a dialog that went blank for that long would read as broken.
*/
/** A track close enough that it is plainly about this day rather than another one. */
const ON_THIS_DAY_KM = 2
function TrackRow({ track, disabled, onPick }: {
track: TrackChoice
disabled: boolean
onPick: () => void
}): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const onDay = track.gapKm <= ON_THIS_DAY_KM
return (
<li>
<button
type="button"
disabled={disabled}
onClick={onPick}
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-start transition-colors hover:bg-surface-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{/* The line's own colour, as a short stroke rather than a dot: it is a line on the
map, and a dot beside a name reads as a place. */}
<span
className="h-1 w-6 shrink-0 rounded-full"
// theme-lint-disable — the colour the track carries, the same one the map draws.
style={{ background: track.color || 'var(--text-faint)' }}
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={{ fontSize: FS.name }}>
{track.name || t('roadtrip.track.untitled')}
</span>
<span className="block truncate tabular-nums text-content-faint" style={{ fontSize: FS.meta }}>
{t('roadtrip.track.length', { distance: formatDistance(track.lengthKm, distanceUnit) })}
{' · '}
{onDay
? t('roadtrip.track.onDay')
: t('roadtrip.track.gap', { distance: formatDistance(track.gapKm, distanceUnit) })}
</span>
</span>
</button>
</li>
)
}
export default function RoadtripTrackModal({ follow, dayNumber }: {
follow: FollowTrack
/** The day's number, so the dialog says which drive it is about to reshape. */
dayNumber: number
}): React.ReactElement | null {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [picked, setPicked] = useState<number | null>(null)
const { dayId, busy, round, error, outcome, tracks, viaCount, current } = follow
// A track picked on one day means nothing on the next.
useEffect(() => { setPicked(null) }, [dayId])
if (dayId === null) return null
const chosen = tracks.find(track => track.id === picked) ?? null
return (
<Modal
isOpen
onClose={follow.close}
size="sm"
title={
<span className="flex items-center gap-2">
<Spline size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.track.title', { number: dayNumber })}
</span>
}
>
<div className="flex flex-col gap-3">
{/* What the day follows today, before anything is picked. Read back from the day
rather than remembered from the run that applied it, which is the whole reason
it is stored: it has to survive a reload. */}
{current ? (
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
{t('roadtrip.track.current', { name: current.name })}
</p>
) : null}
{tracks.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-2">
<EmptyState scene="transport" size={96} compact title={t('roadtrip.track.empty')} />
<p className="text-center text-caption text-content-faint">{t('roadtrip.track.emptyHint')}</p>
</div>
) : (
<ul className="-mx-2 max-h-[280px] overflow-y-auto">
{tracks.map(track => (
<TrackRow
key={track.id}
track={track}
disabled={busy}
onPick={() => setPicked(track.id === picked ? null : track.id)}
/>
))}
</ul>
)}
{/* What the pick will do, before it is done. The count is the honest part: a track
becomes a dozen waypoints on the day, and a traveller who later wonders where
they came from should have been told here. */}
{chosen ? (
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
{t('roadtrip.track.explain')}
</p>
) : null}
{busy ? (
<p className="flex items-center gap-2 text-caption text-content-secondary" role="status">
<RotateCw size={13} className="animate-spin shrink-0" aria-hidden />
{t('roadtrip.track.working', { round })}
</p>
) : null}
{error ? (
<p className="flex items-start gap-1.5 text-caption text-danger" role="alert">
<AlertTriangle size={13} className="mt-px shrink-0" aria-hidden />
{t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')}
</p>
) : null}
{outcome && !busy && !error ? (
<p className="flex items-start gap-1.5 text-caption text-content-secondary" role="status">
<Check size={13} className="mt-px shrink-0 text-success" aria-hidden />
{outcome.vias === 0
? t('roadtrip.track.already')
: outcome.capped
? t('roadtrip.track.capped', {
count: outcome.vias,
distance: formatDistance(outcome.strayKm, distanceUnit),
})
: t('roadtrip.track.done', {
count: outcome.vias,
distance: formatDistance(outcome.strayKm, distanceUnit),
})}
</p>
) : null}
<div className="flex items-center gap-2">
{/* Offered whenever the day carries vias, whether this dialog put them there or
a dragged handle did: "put it back the way the router had it" is the same
wish either way. */}
{viaCount > 0 ? (
<button
type="button"
disabled={busy}
onClick={() => { void follow.clear() }}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
<Trash2 size={13} aria-hidden />
{t('roadtrip.track.clear', { count: viaCount })}
</button>
) : null}
<button
type="button"
disabled={!chosen || busy}
onClick={() => { if (chosen) void follow.apply(chosen.id) }}
className="ms-auto rounded-lg bg-accent px-3 py-2 text-caption font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-40"
>
{t('roadtrip.track.action')}
</button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,117 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import StopKindPicker from './StopKindPicker'
import { STOP_KINDS } from './stopKinds'
/**
* FE-STOPKIND-001..009 — turning a place on the drive into a pause, and back.
*
* Opened from the stop's own number, because the number is what changes: a
* service stop has none. The popover is a portal hung under that element, so
* what is worth pinning is the part a portal makes easy to get wrong — that it
* renders nothing without an anchor, that clicking outside and Escape both close
* it, and that picking the kind a stop already has takes it back off rather than
* setting it twice.
*/
let anchor: HTMLElement
beforeEach(() => {
anchor = document.createElement('button')
document.body.appendChild(anchor)
})
afterEach(() => { anchor.remove() })
function open(over: { current?: string | null; onPick?: (k: string | null) => void; onClose?: () => void } = {}) {
const onPick = over.onPick ?? vi.fn()
const onClose = over.onClose ?? vi.fn()
render(
<StopKindPicker
anchor={anchor}
current={over.current ?? null}
onPick={onPick as never}
onClose={onClose}
/>,
)
return { onPick, onClose }
}
describe('StopKindPicker', () => {
it('FE-STOPKIND-001: without an anchor there is nothing to hang under', () => {
const onPick = vi.fn()
render(<StopKindPicker anchor={null} current={null} onPick={onPick} onClose={vi.fn()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-STOPKIND-002: every kind the rail draws is offered, as a disc rather than a word', () => {
open()
const dialog = screen.getByRole('dialog')
// One button per kind, plus none extra: six rows of text for six icons would
// be a menu where a palette does.
const buttons = dialog.querySelectorAll('button[aria-pressed]')
expect(buttons).toHaveLength(STOP_KINDS.length)
})
it('FE-STOPKIND-003: picking a kind reports it', () => {
const { onPick } = open()
const first = STOP_KINDS[0]
fireEvent.click(screen.getAllByRole('button', { pressed: false })[0])
expect(onPick).toHaveBeenCalledWith(first.key)
})
it('FE-STOPKIND-004: picking the kind it already is takes it back off', () => {
// The control is its own toggle. Without this, changing your mind about a
// fuel stop means finding the separate undo below.
const kind = STOP_KINDS[0].key
const { onPick } = open({ current: kind })
fireEvent.click(screen.getByRole('button', { pressed: true }))
expect(onPick).toHaveBeenCalledWith(null)
})
it('FE-STOPKIND-005: the current kind is the pressed one, and it is the only one', () => {
open({ current: STOP_KINDS[1].key })
expect(screen.getAllByRole('button', { pressed: true })).toHaveLength(1)
})
it('FE-STOPKIND-006: the way back to a destination appears only once there is one', () => {
const { onPick } = open({ current: STOP_KINDS[0].key })
const back = screen.getByRole('button', { name: /destination/i })
fireEvent.click(back)
expect(onPick).toHaveBeenCalledWith(null)
})
it('FE-STOPKIND-007: on an ordinary place it would say "leave everything as it is", so it is absent', () => {
open({ current: null })
expect(screen.queryByRole('button', { name: /destination/i })).not.toBeInTheDocument()
})
it('FE-STOPKIND-008: a click outside closes it, a click inside does not', () => {
const { onClose } = open()
fireEvent.mouseDown(screen.getByRole('dialog'))
expect(onClose).not.toHaveBeenCalled()
// The anchor is not "outside" either: clicking the number again is what
// toggles the picker, and closing here would fight that.
fireEvent.mouseDown(anchor)
expect(onClose).not.toHaveBeenCalled()
fireEvent.mouseDown(document.body)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-STOPKIND-009: Escape closes it, and so does scrolling the rail underneath', () => {
const { onClose } = open()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
// Following the anchor would mean measuring on every frame for a menu that
// is open for two seconds; closing is the cheaper honest answer.
fireEvent.scroll(window)
expect(onClose).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,133 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { MapPin } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { STOP_KINDS } from './stopKinds'
import type { RoadtripStopType } from '@trek/shared'
/**
* Turning a place on the drive into a pause, and back.
*
* Opened from the stop's own number, because the number is exactly what changes: a
* service stop has none. That makes the control its own preview — click the 3, pick the
* pump, and the 3 becomes an orange disc while everything below renumbers itself.
*
* The kinds are the discs the rail already draws rather than a list of words. At this
* size the colour and the shape are the label: a traveller who has seen one petrol stop
* on the map knows the orange pump before reading anything, and six rows of text for six
* icons would be a menu where a palette does.
*
* Only for stops on a drive. A place with no coordinates never reaches the rail, and a
* hotel is not offered here at all, because sleeping somewhere is not a pause in the
* driving — that path goes through the accommodation it books.
*/
const DISC = 'grid place-items-center rounded-full transition-transform'
export default function StopKindPicker({ anchor, current, onPick, onClose }: {
/** The element the popover hangs under, usually the stop's number. */
anchor: HTMLElement | null
current: string | null
onPick: (kind: RoadtripStopType | null) => void
onClose: () => void
}): React.ReactElement | null {
const { t } = useTranslation()
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
// Measured after paint, before the browser shows it: reading the size first and then
// placing it is what stops the panel appearing at 0,0 for one frame.
useLayoutEffect(() => {
if (!anchor || !ref.current) return
const a = anchor.getBoundingClientRect()
const p = ref.current.getBoundingClientRect()
const gap = 8
let top = a.bottom + gap
// Flips above when there is no room below, which is most of the rail on a short
// window: a panel clipped by the viewport is one that cannot be used at all.
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
// Centred on the column, not on the number. The number sits hard against the left
// edge of the rail, so a panel centred on it hangs half off the sidebar and points at
// the map. Vertically it still follows the row it belongs to.
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
const left = Math.min(
Math.max(8, column.left + column.width / 2 - p.width / 2),
window.innerWidth - p.width - 8,
)
setPos({ top, left })
}, [anchor])
useEffect(() => {
if (!anchor) return
const onDown = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (anchor.contains(e.target as Node)) return
onClose()
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
// Any scroll moves the anchor out from under the panel, and following it would mean
// measuring on every frame for a menu that is open for two seconds.
window.addEventListener('scroll', onClose, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onClose, true)
}
}, [anchor, onClose])
if (!anchor) return null
return createPortal(
<div
ref={ref}
role="dialog"
aria-label={t('roadtrip.stop.kind')}
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
>
<div className="flex gap-1">
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
const on = current === key
return (
<Tooltip key={key} label={t(labelKey)}>
<button
type="button"
aria-pressed={on}
aria-label={t(labelKey)}
onClick={() => onPick(on ? null : key)}
className="group grid h-11 w-11 place-items-center rounded-xl transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span
className={`${DISC} h-7 w-7 group-hover:scale-110 ${on ? 'ring-2 ring-offset-2 ring-offset-surface-card' : ''}`}
// theme-lint-disable — the road-signage palette from `roadtripModel`, the
// same one the rail disc and the map pin use, so the choice looks like
// what it will become.
style={{ background: color, color: '#fff', ...(on ? { boxShadow: `0 0 0 2px ${color}` } : {}) }} // theme-lint-disable — road-signage palette
>
<Icon size={14} strokeWidth={2.2} aria-hidden />
</span>
</button>
</Tooltip>
)
})}
</div>
{/* Only once there is something to undo. On an ordinary place it would be a button
that says "leave everything as it is". */}
{current ? (
<button
type="button"
onClick={() => onPick(null)}
className="mt-1 flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<MapPin size={14} className="shrink-0 text-content-faint" aria-hidden />
{t('roadtrip.stop.backToDestination')}
</button>
) : null}
</div>,
document.body,
)
}
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { ACCESS_SPUR_MIN_M, ACCESS_LABEL_MIN_M, spurFor, spurWorthLabelling } from './accessSpur'
import type { SnappedWaypoint } from '../../types'
const snap = (meters: number): SnappedWaypoint => ({
asked: [53.5, 10.0],
at: [53.51, 10.01],
meters,
})
describe('access spur', () => {
it('FE-ACCESSSPUR-001: a gap worth showing becomes the line from the place to the road', () => {
const s = snap(400)
expect(spurFor(s)).toEqual([s.asked, s.at])
})
it('FE-ACCESSSPUR-002: map precision is not a gap', () => {
// A place pinned off an aerial photo lands a few metres from the centreline it
// belongs to all the time. Drawing those would put a dash on nearly every stop.
expect(spurFor(snap(0))).toBeNull()
expect(spurFor(snap(ACCESS_SPUR_MIN_M - 1))).toBeNull()
})
it('FE-ACCESSSPUR-003: the threshold itself counts as a gap', () => {
expect(spurFor(snap(ACCESS_SPUR_MIN_M))).not.toBeNull()
})
it('FE-ACCESSSPUR-004: nothing to draw when the router said nothing', () => {
expect(spurFor(undefined)).toBeNull()
expect(spurFor(null)).toBeNull()
})
it('FE-ACCESSSPUR-005: a NaN distance is not a gap either', () => {
// Recomputed distances can come out NaN from a malformed coordinate, and NaN >= n is
// false — asserted rather than assumed, because the guard reads as a plain comparison.
expect(spurFor(snap(Number.NaN))).toBeNull()
})
it('FE-ACCESSSPUR-006: only a walk long enough to change the plan gets a number', () => {
expect(spurWorthLabelling(ACCESS_LABEL_MIN_M)).toBe(true)
expect(spurWorthLabelling(ACCESS_LABEL_MIN_M - 1)).toBe(false)
expect(spurWorthLabelling(null)).toBe(false)
expect(spurWorthLabelling(undefined)).toBe(false)
})
it('FE-ACCESSSPUR-007: the line is drawn but left unlabelled in between', () => {
// The band where the map says "there is a gap" and the rail stays quiet.
const between = (ACCESS_SPUR_MIN_M + ACCESS_LABEL_MIN_M) / 2
expect(spurFor(snap(between))).not.toBeNull()
expect(spurWorthLabelling(between)).toBe(false)
})
})
@@ -0,0 +1,47 @@
import type { SnappedWaypoint } from '../../types'
/**
* The gap between a place and the road the drive actually reaches it from.
*
* A router snaps every waypoint to the nearest road before it starts, and OSRM does it
* with no distance limit. A place set back from the road is therefore driven to from
* somewhere else, and the line drawn for that drive begins at the somewhere else without
* saying so — which is what a viewpoint above a valley, a farmhouse down a track or a
* marina at the end of a pier all look like today: a route that stops short for no
* visible reason, or worse, one that appears to cut across open ground.
*
* Drawn as a dashed spur rather than hidden or silently absorbed, which is the same
* answer Google gives: the drive ends at the road, the rest is not driving, and the
* picture should say which is which.
*/
/**
* Below this, the gap is map precision rather than missing road.
*
* Thirty metres is roughly the width of a dual carriageway plus its verge, and a place
* pinned from an aerial photo lands that far from the centreline it belongs to all the
* time. Drawing those would put a dash on almost every stop and teach people to ignore
* the mark entirely.
*/
export const ACCESS_SPUR_MIN_M = 30
/**
* Above this, the spur is worth a number as well as a line.
*
* A quarter of a kilometre is where "the car park is over there" turns into something
* that changes the plan: a walk with luggage, a locked gate, a track a hire car should
* not be on. Below it the dash alone says enough.
*/
export const ACCESS_LABEL_MIN_M = 250
/** The two-point line from a place to the road it was routed from, or null if there is none worth drawing. */
export function spurFor(snapped: SnappedWaypoint | undefined | null): [[number, number], [number, number]] | null {
if (!snapped) return null
if (!(snapped.meters >= ACCESS_SPUR_MIN_M)) return null
return [snapped.asked, snapped.at]
}
/** Whether this gap is far enough that the rail should print the distance beside it. */
export function spurWorthLabelling(meters: number | null | undefined): boolean {
return typeof meters === 'number' && meters >= ACCESS_LABEL_MIN_M
}
@@ -0,0 +1,28 @@
/**
* How the offered routes are drawn, after Apple Maps.
*
* All of them blue, not one blue and the rest grey: they are all the same road network
* and all equally drivable, so the difference between them is emphasis, not category. The
* chosen one is the full-strength blue the route already uses; the others are a pale blue
* that stays visible on a grey basemap — Positron is almost entirely greys, and a grey
* line on it disappears, which is exactly what happened when these were grey.
*
* Fixed values rather than theme tokens: they have to match the blue the route itself is
* drawn in, which is fixed too, and a token that shifted with the colour scheme would
* break the pairing.
*/
/** The route as chosen — same blue as the drawn trip route. */
export const ALT_PRIMARY = '#0a84ff'
/** The other offers: unmistakably blue, unmistakably secondary. */
export const ALT_SECONDARY = '#7eb8f0'
/** Under both, so either reads on a pale map, a dark one or a satellite tile. */
export const ALT_CASING = '#ffffff'
/**
* The label colours, again after Apple: the chosen route's time is a filled blue pill,
* the alternatives' are near-black. Both carry white text.
*/
export const ALT_LABEL_PRIMARY_BG = '#0a84ff'
export const ALT_LABEL_SECONDARY_BG = '#2b2f36'
export const ALT_LABEL_TEXT = '#ffffff'
@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest'
import { buildAlternativeOverlays } from './alternativeOverlays'
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
import type { RouteAlternative } from '../Map/RouteCalculator'
/**
* FE-ALTOVL-001..010 — turning the router's answers into something drawable.
*
* Two decisions live here and neither is cosmetic: which of the offered roads is
* drawn as the one you are on, and where each label hangs. A label anchored on a
* stretch two routes share appears pinned to the wrong road, which is the whole
* reason this is not simply the midpoint.
*/
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */
function line(n: number, lat: number): [number, number][] {
return Array.from({ length: n }, (_, i) => [lat, i / 10] as [number, number])
}
function alt(over: Partial<RouteAlternative & { current?: boolean }> = {}): RouteAlternative & { current?: boolean } {
return {
coordinates: line(20, 0),
duration: 3600,
distance: 100_000,
...over,
} as RouteAlternative & { current?: boolean }
}
describe('buildAlternativeOverlays', () => {
it('FE-ALTOVL-001: nothing to choose between is not a choice', () => {
// One answer drawn as an "alternative" would just double the route already
// on the map.
expect(buildAlternativeOverlays(undefined, LABELS)).toEqual([])
expect(buildAlternativeOverlays([], LABELS)).toEqual([])
expect(buildAlternativeOverlays([alt()], LABELS)).toEqual([])
})
it('FE-ALTOVL-002: the quickest is the blue one when no road is being driven yet', () => {
const out = buildAlternativeOverlays(
[alt({ duration: 5400, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
LABELS,
)
expect(out[1].color).toBe(ALT_PRIMARY)
expect(out[1].labelBg).toBe(ALT_LABEL_PRIMARY_BG)
expect(out[0].color).toBe(ALT_SECONDARY)
expect(out[0].labelBg).toBe(ALT_LABEL_SECONDARY_BG)
})
it('FE-ALTOVL-003: the road already being driven takes the blue, even when it is slower', () => {
// Blue means "the road you are on". Handing it to the quickest offer instead
// would recolour the drive the traveller deliberately bent.
const out = buildAlternativeOverlays(
[alt({ duration: 7200, current: true, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
LABELS,
)
expect(out[0].color).toBe(ALT_PRIMARY)
expect(out[0].note).toBe('Current')
expect(out[1].color).toBe(ALT_SECONDARY)
})
it('FE-ALTOVL-004: what a road IS beats what it is not', () => {
// A way offered because the motorway was left out says so, rather than being
// labelled by how much slower it is — being off the motorway is the reason
// somebody would take it.
const out = buildAlternativeOverlays(
[
alt({ duration: 3600, coordinates: line(20, 0) }),
alt({ duration: 5400, avoids: 'motorway', coordinates: line(20, 1) }),
alt({ duration: 5400, avoids: 'toll', coordinates: line(20, 2) }),
],
LABELS,
)
expect(out.map(o => o.note)).toEqual(['Fastest', 'No motorway', 'No tolls'])
})
it('FE-ALTOVL-005: a plain slower offer carries no note at all', () => {
const out = buildAlternativeOverlays(
[alt({ duration: 3600, coordinates: line(20, 0) }), alt({ duration: 5400, coordinates: line(20, 1) })],
LABELS,
)
expect(out[1].note).toBe('')
})
it('FE-ALTOVL-006: how much slower is measured against the quickest, and never negative', () => {
const out = buildAlternativeOverlays(
[alt({ duration: 5400, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
LABELS,
)
expect(out[0].slowerThanQuickest).toBe(1800)
expect(out[1].slowerThanQuickest).toBe(0)
expect(out.every(o => o.slowerThanQuickest >= 0)).toBe(true)
})
it('FE-ALTOVL-007: a label hangs where its own road is unlike every other', () => {
// Two roads that share both ends and part in the middle. The midpoint of the
// shared stretch would put both labels on the same line; the point furthest
// from the other route puts each on the stretch only it uses.
const shared = (lat: number): [number, number][] => [
[0, 0], [0, 1], [lat, 2], [lat, 3], [0, 4], [0, 5],
]
const out = buildAlternativeOverlays(
[alt({ coordinates: shared(0.5) }), alt({ coordinates: shared(-0.5) })],
LABELS,
)
// Each label sits on its own detour, not on the common ends.
expect(Math.abs(out[0].at.lat)).toBeCloseTo(0.5, 5)
expect(Math.abs(out[1].at.lat)).toBeCloseTo(0.5, 5)
expect(out[0].at.lat).not.toBe(out[1].at.lat)
})
it('FE-ALTOVL-008: a route with no coordinates gets a label anchor rather than crashing', () => {
const out = buildAlternativeOverlays(
[alt({ coordinates: [] }), alt({ coordinates: line(20, 1) })],
LABELS,
)
expect(out).toHaveLength(2)
expect(out[0].at).toEqual({ lat: 0, lng: 0 })
})
it('FE-ALTOVL-009: the drive time is carried through as text and as seconds', () => {
const out = buildAlternativeOverlays(
[alt({ duration: 3600, distance: 42_000 }), alt({ duration: 5400, coordinates: line(20, 1) })],
LABELS,
)
expect(out[0].duration).toBe(3600)
expect(out[0].distance).toBe(42_000)
// The label is the readable form of the same number.
expect(out[0].label).toBeTruthy()
expect(out[0].index).toBe(0)
expect(out[1].index).toBe(1)
})
it('FE-ALTOVL-010: only one road is ever the blue one', () => {
const out = buildAlternativeOverlays(
[
alt({ duration: 3600, coordinates: line(20, 0) }),
alt({ duration: 3600, coordinates: line(20, 1) }),
alt({ duration: 3600, coordinates: line(20, 2) }),
],
LABELS,
)
expect(out.filter(o => o.color === ALT_PRIMARY)).toHaveLength(1)
})
})
@@ -0,0 +1,125 @@
import { formatDurationShort } from './roadtripModel'
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
import type { RouteAlternative } from '../Map/RouteCalculator'
/** One offered route, ready to draw: the line, its colour, and where its label sits. */
export interface AlternativeOverlay {
index: number
coordinates: [number, number][]
color: string
/** The drive time, the way Apple Maps puts it on the road itself. */
label: string
/** The second line under it — "Fastest", "Current" — or empty for the plain ones. */
note: string
/** How long this way takes and how far it is, for the list beside the map. */
duration: number
distance: number
/** Seconds more than the quickest offer; negative never happens by construction. */
slowerThanQuickest: number
labelBg: string
/** Where to hang the label — a point on this route and on no other. */
at: { lat: number; lng: number }
}
/**
* Squared distance between two points, in degrees, with longitude scaled by latitude.
*
* Only ever compared against other values from this same function, so degrees are fine
* and a haversine would cost more for an answer nobody reads.
*/
function roughDist(a: [number, number], b: [number, number]): number {
const dLat = a[0] - b[0]
const dLng = (a[1] - b[1]) * Math.cos((a[0] * Math.PI) / 180)
return dLat * dLat + dLng * dLng
}
/**
* The point of `line` that lies furthest from every one of `others`.
*
* This is where a route is unmistakably itself. Anchoring a label at the geometric
* midpoint instead put it wherever that happened to fall — and on a pair of routes that
* split early and rejoin, the midpoint sits on the shared stretch, so the label appeared
* to be pinned to the wrong road.
*
* Sampled rather than exhaustive: alternatives differ over kilometres, and comparing two
* thousand-point lines in full costs milliseconds for a pixel of accuracy.
*/
function mostDistinctPoint(
line: [number, number][],
others: [number, number][][],
): { lat: number; lng: number } | null {
if (!line.length) return null
if (!others.length) {
const mid = line[Math.floor(line.length / 2)]
return mid ? { lat: mid[0], lng: mid[1] } : null
}
const sample = (l: [number, number][]) => Math.max(1, Math.floor(l.length / 150))
let best: [number, number] | null = null
let bestScore = -1
for (let i = 0; i < line.length; i += sample(line)) {
// How far this point is from the NEAREST other route: a point close to any of them
// is not distinctive, however far it sits from the rest.
let nearestOfAll = Infinity
for (const other of others) {
let nearest = Infinity
const step = sample(other)
for (let j = 0; j < other.length; j += step) {
const d = roughDist(line[i], other[j])
if (d < nearest) nearest = d
}
if (nearest < nearestOfAll) nearestOfAll = nearest
}
if (nearestOfAll > bestScore) { bestScore = nearestOfAll; best = line[i] }
}
return best ? { lat: best[0], lng: best[1] } : null
}
/**
* The routes as the map should draw them, built once so every renderer agrees.
*
* Each label hangs where its own route is furthest from all the others, so a label always
* sits on a stretch only that route uses. Two routes that share their first and last
* thirds still get their labels on the middle third, where they actually differ.
*/
export function buildAlternativeOverlays(
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
): AlternativeOverlay[] {
if (!routes?.length) return []
// A single answer is not a choice; drawing it would just double the route already there.
if (routes.length < 2) return []
// The quickest of what came back, which is what Apple calls out — not necessarily the
// first entry, since the road currently driven is put at the top when there is one.
const quickest = routes.reduce((best, r, i) => (r.duration < routes[best].duration ? i : best), 0)
const anyCurrent = routes.some(r => r.current)
return routes.map((route, index) => {
const others = routes.filter((_, i) => i !== index).map(r => r.coordinates)
const at = mostDistinctPoint(route.coordinates, others)
// Blue is the road you are on: the one currently driven, or the router's own pick
// when nothing has been bent. Everything else is the pale blue of an offer.
const primary = route.current || (!anyCurrent && index === quickest)
return {
index,
coordinates: route.coordinates,
color: primary ? ALT_PRIMARY : ALT_SECONDARY,
label: formatDurationShort(route.duration),
// What this way IS beats what it is not: a road offered because the motorway was
// left out of it says so, rather than being labelled by how much slower it is —
// that is the reason somebody would take it.
note: route.current
? labels.current
: route.avoids === 'motorway'
? labels.noMotorway
: route.avoids === 'toll'
? labels.noToll
: index === quickest ? labels.fastest : '',
duration: route.duration,
distance: route.distance,
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG,
at: at ?? { lat: 0, lng: 0 },
}
})
}
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest'
import {
corridorTiles,
distanceToSegmentKm,
haversineKm,
projectOntoRoute,
simplifyLine,
type LatLng,
} from './corridor'
const BERLIN: LatLng = { lat: 52.52, lng: 13.405 }
const DRESDEN: LatLng = { lat: 51.05, lng: 13.74 }
const PRAGUE: LatLng = { lat: 50.088, lng: 14.42 }
describe('haversineKm', () => {
it('measures a known distance', () => {
// Berlin → Dresden is about 165 km as the crow flies.
expect(haversineKm(BERLIN, DRESDEN)).toBeGreaterThan(160)
expect(haversineKm(BERLIN, DRESDEN)).toBeLessThan(170)
})
it('is zero for the same point', () => {
expect(haversineKm(BERLIN, BERLIN)).toBe(0)
})
})
describe('distanceToSegmentKm', () => {
const a: LatLng = { lat: 52, lng: 13 }
const b: LatLng = { lat: 52, lng: 14 }
it('measures perpendicular distance to the line', () => {
const p: LatLng = { lat: 52.09, lng: 13.5 }
// 0.09° of latitude is about 10 km, whatever the longitude does.
expect(distanceToSegmentKm(p, a, b)).toBeGreaterThan(9)
expect(distanceToSegmentKm(p, a, b)).toBeLessThan(11)
})
it('measures to the end, not to the infinite line, for a point beyond it', () => {
const beyond: LatLng = { lat: 52, lng: 16 }
const d = distanceToSegmentKm(beyond, a, b)
// Two degrees of longitude past the end at 52° north — roughly 137 km.
expect(d).toBeGreaterThan(120)
expect(d).toBeLessThan(150)
})
it('falls back to point distance for a zero-length segment', () => {
expect(distanceToSegmentKm(DRESDEN, a, a)).toBeCloseTo(haversineKm(DRESDEN, a), 5)
})
})
describe('projectOntoRoute', () => {
const line = [BERLIN, DRESDEN, PRAGUE]
it('reports how far off the route a point is', () => {
const onRoute = projectOntoRoute({ lat: 51.8, lng: 13.5 }, line)
expect(onRoute!.offRouteKm).toBeLessThan(15)
const wayOff = projectOntoRoute({ lat: 53.55, lng: 10.0 }, line)
expect(wayOff!.offRouteKm).toBeGreaterThan(150)
})
it('orders points by how far into the drive they come', () => {
const early = projectOntoRoute({ lat: 52.3, lng: 13.45 }, line)!
const late = projectOntoRoute({ lat: 50.4, lng: 14.2 }, line)!
expect(early.alongKm).toBeLessThan(late.alongKm)
})
it('tells two points on the same long straight apart', () => {
// The petrol stations along one motorway segment must not all report the same
// position in the drive, which is what taking the segment midpoint produced.
const straight = [{ lat: 52, lng: 13 }, { lat: 52, lng: 15 }]
const early = projectOntoRoute({ lat: 52.01, lng: 13.3 }, straight)!
const late = projectOntoRoute({ lat: 52.01, lng: 14.7 }, straight)!
expect(late.alongKm - early.alongKm).toBeGreaterThan(80)
})
it('returns nothing for a line that is not one', () => {
expect(projectOntoRoute(BERLIN, [])).toBeNull()
expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull()
})
})
describe('corridorTiles', () => {
it('covers a short route with a single box', () => {
const tiles = corridorTiles([BERLIN, { lat: 52.4, lng: 13.5 }], 5)
expect(tiles).toHaveLength(1)
expect(tiles[0].south).toBeLessThan(52.4)
expect(tiles[0].north).toBeGreaterThan(52.52)
})
it('splits a long route into boxes the server will not clamp', () => {
const tiles = corridorTiles([BERLIN, DRESDEN, PRAGUE], 10)
expect(tiles.length).toBeGreaterThan(1)
for (const t of tiles) {
expect(t.north - t.south).toBeLessThanOrEqual(0.45)
expect(t.east - t.west).toBeLessThanOrEqual(0.45)
}
})
it('pads longitude more the further north it goes', () => {
const south = corridorTiles([{ lat: 0, lng: 0 }], 10)[0]
const north = corridorTiles([{ lat: 60, lng: 0 }], 10)[0]
expect(north.east - north.west).toBeGreaterThan(south.east - south.west)
})
it('covers the middle of a long straight leg, not only its two ends', () => {
// A motorway with no bend in it reduces to two points, 250 km apart. Tiling walks
// point to point, so without splitting the step everything between them goes
// unsearched while the search still reports itself complete.
const straight = [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }]
const tiles = corridorTiles(straight, 10)
expect(tiles.length).toBeGreaterThan(4)
// A box somewhere over the middle of the drive, which is what was missing.
const midLat = 53.03
const midLng = 11.7
expect(tiles.some(t => t.south <= midLat && t.north >= midLat && t.west <= midLng && t.east >= midLng)).toBe(true)
})
it('leaves no gap between consecutive boxes along a route', () => {
const tiles = corridorTiles([{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], 10)
for (let i = 1; i < tiles.length; i++) {
const a = tiles[i - 1]
const b = tiles[i]
// Overlapping or touching in both axes: a hole here is a stretch nobody looks at.
expect(Math.min(a.north, b.north) - Math.max(a.south, b.south)).toBeGreaterThanOrEqual(0)
expect(Math.min(a.east, b.east) - Math.max(a.west, b.west)).toBeGreaterThanOrEqual(0)
}
})
it('has nothing to cover for an empty route', () => {
expect(corridorTiles([], 10)).toEqual([])
})
})
describe('simplifyLine', () => {
it('keeps the ends and drops what sits on the line between them', () => {
const straight = [
{ lat: 52, lng: 13 },
{ lat: 52, lng: 13.5 },
{ lat: 52, lng: 14 },
]
expect(simplifyLine(straight, 1)).toEqual([straight[0], straight[2]])
})
it('keeps a point that actually bends the route', () => {
const bent = [
{ lat: 52, lng: 13 },
{ lat: 52.5, lng: 13.5 },
{ lat: 52, lng: 14 },
]
expect(simplifyLine(bent, 1)).toHaveLength(3)
})
it('leaves a two-point line alone', () => {
expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN])
})
})
+234
View File
@@ -0,0 +1,234 @@
/**
* Corridor geometry — "what lies along this drive", without asking Overpass to think
* about a polyline.
*
* The naive form (`around:` with a hundred coordinates) is what every POI provider warns
* about and times out on. OsmAnd solves it the other way round and this follows it: ask
* for plain bounding boxes that cover the route, then measure each candidate's
* perpendicular distance to the line and throw away what is too far. The boxes are what
* the existing /api/maps/pois route already accepts, so no new server surface is needed.
*
* Everything here is pure: no React, no network, no store.
*/
export interface LatLng {
lat: number
lng: number
}
export interface Bbox {
south: number
west: number
north: number
east: number
}
const EARTH_RADIUS_KM = 6371
const KM_PER_DEG_LAT = 111.32
const toRad = (deg: number): number => (deg * Math.PI) / 180
/** Great-circle distance in kilometres. */
export function haversineKm(a: LatLng, b: LatLng): number {
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const h =
Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)))
}
/**
* Perpendicular distance from a point to a line segment, in kilometres.
*
* Works in a local equirectangular projection around the segment: longitudes shrink by
* cos(lat), which is the whole reason a uniform degree padding is wrong at 60° north.
* Over the few kilometres a corridor spans, the error against a proper geodesic is far
* below the width the user picked.
*/
export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number {
return projectOnSegment(p, a, b).distanceKm
}
/**
* Perpendicular distance to a segment plus where along it the foot of that perpendicular
* falls (0 at `a`, 1 at `b`). The second number is what places a candidate correctly in
* the drive — taking the segment's midpoint instead makes every petrol station on a long
* straight report the same position.
*/
/** A segment shorter than a millimetre, squared — below this there is nothing to project onto. */
const DEGENERATE_SEGMENT_KM2 = 1e-12
export function projectOnSegment(p: LatLng, a: LatLng, b: LatLng): { distanceKm: number; t: number } {
const latScale = KM_PER_DEG_LAT
const lngScale = KM_PER_DEG_LAT * Math.cos(toRad((a.lat + b.lat) / 2))
const ax = a.lng * lngScale
const ay = a.lat * latScale
const bx = b.lng * lngScale
const by = b.lat * latScale
const px = p.lng * lngScale
const py = p.lat * latScale
const dx = bx - ax
const dy = by - ay
const lenSq = dx * dx + dy * dy
// A segment whose two ends are the same point to within a millimetre has no
// direction to project onto, so measure straight to it. Written as a range because
// the ends came through a chain of floating point arithmetic and never land on 0.
if (lenSq < DEGENERATE_SEGMENT_KM2) return { distanceKm: haversineKm(p, a), t: 0 }
// Clamped so a point beside the segment's end measures to the end, not to the
// infinite line — otherwise a stop 200 km past the destination looks "on the way".
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq))
const cx = ax + t * dx
const cy = ay + t * dy
return { distanceKm: Math.hypot(px - cx, py - cy), t }
}
export interface CorridorHit {
/** Kilometres from the route line at its closest point. */
offRouteKm: number
/** Kilometres travelled along the route before reaching that closest point. */
alongKm: number
}
/**
* Where a point sits relative to the route: how far off it is, and how far into the
* drive it comes. The second number is what puts "lunch" before "the hotel" in a list
* instead of sorting by name.
*/
export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null {
if (line.length < 2) return null
let best = Number.POSITIVE_INFINITY
let bestAlong = 0
let travelled = 0
for (let i = 0; i < line.length - 1; i++) {
const a = line[i]
const b = line[i + 1]
const segment = haversineKm(a, b)
const { distanceKm: d, t } = projectOnSegment(p, a, b)
if (d < best) {
best = d
bestAlong = travelled + t * segment
}
travelled += segment
}
return { offRouteKm: best, alongKm: bestAlong }
}
/**
* Bounding boxes covering the route, each no larger than `maxSpanDeg`.
*
* The server clamps any box bigger than that (MAX_BBOX_SPAN_DEG) so Overpass is not
* asked to scan a continent, which means a long drive has to be cut into boxes here
* rather than sent as one. Boxes are grown along the route until one would exceed the
* span, so a straight motorway needs far fewer requests than a mountain road.
*/
/**
* Splits a step that is longer than a box into even pieces.
*
* The simplifier is right to reduce a dead straight motorway stretch to its two ends,
* but tiling walks point to point: two ends 100 km apart produce a box at each end and
* nothing in between, so the middle of the drive would never be searched.
*/
function densify(line: LatLng[], maxStepDeg: number): LatLng[] {
if (line.length < 2) return line
const out: LatLng[] = [line[0]]
for (let i = 1; i < line.length; i++) {
const a = line[i - 1]
const b = line[i]
const span = Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lng - a.lng))
const steps = Math.max(1, Math.ceil(span / maxStepDeg))
for (let s = 1; s <= steps; s++) {
out.push({
lat: a.lat + ((b.lat - a.lat) * s) / steps,
lng: a.lng + ((b.lng - a.lng) * s) / steps,
})
}
}
return out
}
export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45): Bbox[] {
if (line.length === 0) return []
const padLat = widthKm / KM_PER_DEG_LAT
const tiles: Bbox[] = []
let current: Bbox | null = null
const padded = (b: Bbox): Bbox => {
// Longitude degrees shrink towards the poles, so the padding has to grow with
// latitude or the corridor is narrower than asked for in the north.
const midLat = (b.north + b.south) / 2
const padLng = padLat / Math.max(0.2, Math.cos(toRad(midLat)))
return {
south: b.south - padLat,
north: b.north + padLat,
west: b.west - padLng,
east: b.east + padLng,
}
}
// The point the open box last swallowed. When a box closes, the next one starts
// from it rather than from `p` alone, so the two overlap by exactly one step of
// the walk. Starting at `p` left the stretch between them covered only by the
// padding on either side — and the walk steps in up to maxSpanDeg/2 while the
// padding is widthKm, so at the offered widths a band of several kilometres of
// route fell into no box at all. Nothing downstream noticed: the panel counts
// tiles asked and tiles answered, so it reported a finished, uncapped,
// error-free search of a drive it had never fully looked at. "Find fuel along
// the route" is the whole feature.
let previous: LatLng | null = null
for (const p of densify(line, maxSpanDeg / 2)) {
if (!current) {
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }
previous = p
continue
}
const next: Bbox = {
south: Math.min(current.south, p.lat),
north: Math.max(current.north, p.lat),
west: Math.min(current.west, p.lng),
east: Math.max(current.east, p.lng),
}
const grown = padded(next)
if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) {
tiles.push(padded(current))
const from = previous ?? p
current = {
south: Math.min(from.lat, p.lat),
north: Math.max(from.lat, p.lat),
west: Math.min(from.lng, p.lng),
east: Math.max(from.lng, p.lng),
}
} else {
current = next
}
previous = p
}
if (current) tiles.push(padded(current))
return tiles
}
/**
* Thins a route down to the points that still describe it, so the corridor is built
* from tens of coordinates rather than the thousands a routed geometry carries.
* Ramer-Douglas-Peucker with the tolerance expressed in kilometres.
*/
export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
if (line.length < 3) return line.slice()
let maxDist = 0
let index = 0
const first = line[0]
const last = line[line.length - 1]
for (let i = 1; i < line.length - 1; i++) {
const d = distanceToSegmentKm(line[i], first, last)
if (d > maxDist) {
maxDist = d
index = i
}
}
if (maxDist <= toleranceKm) return [first, last]
const left = simplifyLine(line.slice(0, index + 1), toleranceKm)
const right = simplifyLine(line.slice(index), toleranceKm)
return [...left.slice(0, -1), ...right]
}
@@ -0,0 +1,238 @@
import { describe, it, expect } from 'vitest'
import { haversineKm, type LatLng } from './corridor'
import {
parseTrack, lineLengthKm, thin, nearestIndex, orient, stopIndices, trackGapKm, planTrack,
} from './followTrack'
/**
* The refinement is the interesting part, and it is testable without a router: what it
* needs is something that answers "the road I would drive through these waypoints", and
* the straight line between them is exactly the answer that makes the loop work for its
* living. A track that bulges away from that chord is the shape every scenic route has.
*/
/** A track arching north away from the straight line between its ends. */
function arc(points = 120, bulgeDeg = 0.1): LatLng[] {
return Array.from({ length: points + 1 }, (_, i) => ({
lat: 52 + bulgeDeg * Math.sin((Math.PI * i) / points),
lng: 13 + i / points,
}))
}
const START: LatLng = { lat: 52, lng: 13 }
const END: LatLng = { lat: 52, lng: 14 }
/** A router with no idea about roads: it drives straight from waypoint to waypoint. */
function straightRouter(): { route: (w: LatLng[]) => Promise<LatLng[]>; calls: LatLng[][] } {
const calls: LatLng[][] = []
return {
calls,
route: async (waypoints: LatLng[]) => {
calls.push(waypoints)
return waypoints
},
}
}
describe('parseTrack', () => {
it('FE-FOLLOWTRACK-001: reads pairs and triples, and steps over what is neither', () => {
const geometry = JSON.stringify([[52, 13], [52.1, 13.1, 340], ['x', 1], [1], null, [53, 14]])
expect(parseTrack(geometry)).toEqual([
{ lat: 52, lng: 13 },
{ lat: 52.1, lng: 13.1 },
{ lat: 53, lng: 14 },
])
})
it('FE-FOLLOWTRACK-002: a track that is not a track is nothing, never a throw', () => {
// Somebody else's column, filled by an importer that has seen every malformed GPX
// there is. A day that cannot follow a track is a day; an exception here is a page.
expect(parseTrack('not json')).toEqual([])
expect(parseTrack('{"type":"LineString"}')).toEqual([])
expect(parseTrack(null)).toEqual([])
expect(parseTrack(undefined)).toEqual([])
})
})
describe('thinning and measuring', () => {
it('FE-FOLLOWTRACK-003: thinning keeps both ends, whatever the stride', () => {
const line = arc(1000)
const thinned = thin(line, 400)
expect(thinned.length).toBeLessThanOrEqual(401)
expect(thinned[0]).toEqual(line[0])
expect(thinned[thinned.length - 1]).toEqual(line[line.length - 1])
})
it('FE-FOLLOWTRACK-004: a line shorter than the cap comes back untouched', () => {
const line = arc(10)
expect(thin(line, 400)).toBe(line)
})
it('FE-FOLLOWTRACK-005: an arc is longer than the chord it spans', () => {
expect(lineLengthKm(arc())).toBeGreaterThan(haversineKm(START, END))
})
it('FE-FOLLOWTRACK-006: the nearest point of a track is found by position, not by order', () => {
const line = arc(20)
expect(nearestIndex(line, line[7])).toBe(7)
})
})
describe('orientation', () => {
it('FE-FOLLOWTRACK-007: a track recorded the other way round is turned', () => {
// Half of Norway's scenic routes are published southbound. Without this the vias come
// out in reverse order and the day drives the loop backwards.
const backwards = [...arc(20)].reverse()
const turned = orient(backwards, [START, END])
expect(turned[0].lng).toBeLessThan(turned[turned.length - 1].lng)
})
it('FE-FOLLOWTRACK-008: a track already pointing the right way is left alone', () => {
const line = arc(20)
expect(orient(line, [START, END])).toBe(line)
})
it('FE-FOLLOWTRACK-009: stops never walk backwards along the track', () => {
// A track that doubles back has two nearest points for the same stop, and the wrong
// one would put a via on the leg before it.
const line = arc(40)
const stops = [START, { lat: 52.05, lng: 13.5 }, END]
const idx = stopIndices(line, stops)
expect(idx[0]).toBeLessThanOrEqual(idx[1])
expect(idx[1]).toBeLessThanOrEqual(idx[2])
})
})
describe('planTrack', () => {
it('FE-FOLLOWTRACK-010: a road that already follows the track needs no vias', async () => {
const track = arc()
const plan = await planTrack({ track, stops: [START, END], route: async () => track })
expect(plan.vias).toEqual([])
expect(plan.rounds).toBe(1)
expect(plan.capped).toBe(false)
// Still names the leg it runs along, so applying it clears what an earlier pass left.
expect(plan.legs).toEqual([0])
})
it('FE-FOLLOWTRACK-011: it refines until the road is inside the tolerance', async () => {
const { route, calls } = straightRouter()
const plan = await planTrack({ track: arc(), stops: [START, END], route, options: { toleranceKm: 1 } })
expect(plan.capped).toBe(false)
expect(plan.strayKm).toBeLessThanOrEqual(1)
expect(plan.vias.length).toBeGreaterThan(0)
// One route per round, and the waypoint list grows by exactly the via just added.
expect(calls.length).toBe(plan.rounds)
expect(calls[calls.length - 1].length).toBe(2 + plan.vias.length)
})
it('FE-FOLLOWTRACK-012: every via lands on the track, in driving order', async () => {
const { route } = straightRouter()
const track = arc()
const plan = await planTrack({ track, stops: [START, END], route, options: { toleranceKm: 1 } })
for (const via of plan.vias) {
expect(track.some(p => haversineKm(p, via) < 0.001)).toBe(true)
}
const longitudes = plan.vias.map(v => v.lng)
expect([...longitudes].sort((a, b) => a - b)).toEqual(longitudes)
})
it('FE-FOLLOWTRACK-013: no via is laid down on top of a stop', async () => {
// A via a hundred metres from a stop costs a waypoint to tell the router what the
// stop already told it.
const { route } = straightRouter()
const plan = await planTrack({ track: arc(), stops: [START, END], route, options: { toleranceKm: 0.2, maxVias: 8 } })
for (const via of plan.vias) {
expect(haversineKm(via, START)).toBeGreaterThanOrEqual(0.6)
expect(haversineKm(via, END)).toBeGreaterThanOrEqual(0.6)
}
})
it('FE-FOLLOWTRACK-014: a via hangs behind the stop whose leg it shapes', async () => {
const { route } = straightRouter()
// Three stops, and the bulge is entirely on the second leg.
const straight: LatLng[] = Array.from({ length: 40 }, (_, i) => ({ lat: 52, lng: 13 + i / 78 }))
const bulge: LatLng[] = Array.from({ length: 81 }, (_, i) => ({
lat: 52 + 0.1 * Math.sin((Math.PI * i) / 80),
lng: 13.5 + i / 160,
}))
const middle: LatLng = { lat: 52, lng: 13.5 }
const plan = await planTrack({
track: [...straight, ...bulge],
stops: [START, middle, END],
route,
options: { toleranceKm: 1 },
})
expect(plan.vias.length).toBeGreaterThan(0)
expect(plan.vias.every(v => v.after_order_index === 1)).toBe(true)
expect(plan.legs).toEqual([0, 1])
})
it('FE-FOLLOWTRACK-015: it gives up rather than spending the day on a road that will not fit', async () => {
const { route, calls } = straightRouter()
const plan = await planTrack({
track: arc(),
stops: [START, END],
route,
options: { toleranceKm: 0.0001, maxVias: 3 },
})
expect(plan.capped).toBe(true)
expect(plan.vias).toHaveLength(3)
expect(calls.length).toBe(4)
})
it('FE-FOLLOWTRACK-016: a track the day only brushes past is not a route for it', async () => {
const { route, calls } = straightRouter()
const plan = await planTrack({ track: [{ lat: 40, lng: 2 }, { lat: 40.001, lng: 2.001 }], stops: [START, END], route })
expect(plan.vias).toEqual([])
expect(plan.legs).toEqual([])
// Never asks the router about a track that has nothing to do with the day.
expect(calls).toHaveLength(0)
})
it('FE-FOLLOWTRACK-017: nothing to route through is not an error', async () => {
const { route } = straightRouter()
expect((await planTrack({ track: arc(), stops: [START], route })).vias).toEqual([])
expect((await planTrack({ track: [], stops: [START, END], route })).vias).toEqual([])
})
it('FE-FOLLOWTRACK-018: a router that answers with no road says so', async () => {
await expect(planTrack({ track: arc(), stops: [START, END], route: async () => [] }))
.rejects.toThrow(/road/)
})
it('FE-FOLLOWTRACK-019: an aborted plan stops where it is', async () => {
const controller = new AbortController()
const plan = await planTrack({
track: arc(),
stops: [START, END],
route: async () => [START, END],
signal: controller.signal,
options: { toleranceKm: 0.001 },
})
controller.abort()
// Ran to its own conclusion here; the point is that the signal is honoured at the top
// of each round, which the aborted-before-start case proves.
expect(plan.rounds).toBeGreaterThan(0)
const stopped = new AbortController()
stopped.abort()
const none = await planTrack({
track: arc(),
stops: [START, END],
route: async () => [START, END],
signal: stopped.signal,
})
expect(none.rounds).toBe(0)
})
it('FE-FOLLOWTRACK-020: how far a track runs from a day is what orders a list of them', async () => {
const near = trackGapKm(arc(), [START, END])
const far = trackGapKm(arc().map(p => ({ lat: p.lat + 3, lng: p.lng })), [START, END])
expect(near).toBeLessThan(far)
expect(trackGapKm([], [START])).toBe(Number.POSITIVE_INFINITY)
})
})
@@ -0,0 +1,318 @@
import { haversineKm, projectOntoRoute, type LatLng } from './corridor'
/**
* Making a day's drive follow an imported track.
*
* A GPX track is a shape, not a route: a list of positions with no idea which roads they
* were recorded on. Drawing it beside the day is easy and useless — the distance, the
* drive time and every warning still belong to the motorway the router picked, so the
* plan says four hours while the road nobody is taking says seven. Norway publishes its
* scenic routes this way, and so does every touring club worth importing from.
*
* What turns a shape into a route the day actually takes is a handful of vias placed on
* it, because a via is the one thing a router treats as "go through here". Placing them
* is the whole problem. Every point of the track would route exactly and take a hundred
* requests; two would route in seconds and follow nothing.
*
* So this refines: route through what we have, find the point of the track the road
* misses by the most, make that point a via, and go again. Each round roughly halves the
* worst miss, because the new via lands in the middle of whatever the last one left
* unfollowed — which is why a scenic route usually fits inside ten of them, and why the
* loop stops when the road is close enough rather than after a fixed count.
*
* Everything here is pure apart from the injected router: no React, no store, no api.
*/
/** Track points kept for measuring — enough to catch a hairpin, few enough to be fast. */
const MEASURE_POINTS = 400
/** Routed points kept for measuring. Coarser: it is the thing being measured against. */
const ROUTE_POINTS = 2000
/**
* How close a via may sit to a stop, or to another via, before it is pointless.
*
* A via a hundred metres from a stop tells the router nothing it did not already know
* from the stop, and two vias in the same lay-by cost a waypoint each to say one thing.
*/
const MIN_GAP_KM = 0.6
export interface FollowTrackOptions {
/** How close the road must come to the track before the plan is done, in kilometres. */
toleranceKm?: number
/** The most vias the plan may lay down. */
maxVias?: number
/** The most routing round trips it may spend. */
maxRounds?: number
}
export interface TrackVia {
/** The stop the via hangs behind, as an index into the day's stops. */
after_order_index: number
lat: number
lng: number
}
export interface FollowTrackPlan {
/** The vias to write, in driving order, each already on the leg it shapes. */
vias: TrackVia[]
/** The legs the track runs along — cleared first, so applying twice does not stack. */
legs: number[]
/** How far the road still strays from the track at its worst point, in kilometres. */
strayKm: number
/** Routing round trips spent. */
rounds: number
/** True when it ran out of vias or rounds before the road fitted the track. */
capped: boolean
}
const EMPTY_PLAN: FollowTrackPlan = { vias: [], legs: [], strayKm: 0, rounds: 0, capped: false }
/**
* The points of a stored track.
*
* `route_geometry` is the column the GPX and KML importers write, holding `[lat, lng]`
* pairs or `[lat, lng, ele]` triples depending on whether every point carried an
* elevation. Anything else in there is somebody else's data — a malformed track is worth
* skipping, never worth throwing over.
*/
export function parseTrack(routeGeometry: string | null | undefined): LatLng[] {
if (!routeGeometry) return []
try {
const raw: unknown = JSON.parse(routeGeometry)
if (!Array.isArray(raw)) return []
const out: LatLng[] = []
for (const point of raw) {
if (!Array.isArray(point) || point.length < 2) continue
const lat = Number(point[0])
const lng = Number(point[1])
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
out.push({ lat, lng })
}
return out
} catch {
return []
}
}
/** How long a polyline is, in kilometres. */
export function lineLengthKm(line: LatLng[]): number {
let km = 0
for (let i = 1; i < line.length; i++) km += haversineKm(line[i - 1], line[i])
return km
}
/**
* Every nth point, keeping both ends.
*
* Deliberately not the Douglas-Peucker in `corridor.ts`: that one is recursive and slices
* the array at every step, which is fine for a routed leg and quadratic on a recorded
* track of thirty thousand points. Taking a stride loses a little accuracy on a hairpin
* and none of the shape, and the tolerance this feeds is measured in kilometres.
*/
export function thin(line: LatLng[], max: number): LatLng[] {
if (line.length <= max) return line
const stride = Math.ceil(line.length / max)
const out: LatLng[] = []
for (let i = 0; i < line.length; i += stride) out.push(line[i])
const last = line[line.length - 1]
if (out[out.length - 1] !== last) out.push(last)
return out
}
/** Index of the point of `line` closest to `p`. */
export function nearestIndex(line: LatLng[], p: LatLng): number {
let best = Number.POSITIVE_INFINITY
let at = 0
for (let i = 0; i < line.length; i++) {
const d = haversineKm(line[i], p)
if (d < best) {
best = d
at = i
}
}
return at
}
/** How far a point lies from a line, in kilometres. Infinite for a line of one point. */
export function offLineKm(line: LatLng[], p: LatLng): number {
return projectOntoRoute(p, line)?.offRouteKm ?? Number.POSITIVE_INFINITY
}
/**
* The track pointing the way the day is driven.
*
* A recorded track has a direction, and it is whichever way the person who recorded it
* happened to drive. Half of Norway's scenic routes are published southbound. Reversed
* where the day's last stop comes before its first, so everything downstream can treat a
* higher index as "further along the drive".
*/
export function orient(track: LatLng[], stops: LatLng[]): LatLng[] {
if (track.length < 2 || stops.length < 2) return track
const start = nearestIndex(track, stops[0])
const end = nearestIndex(track, stops[stops.length - 1])
return end < start ? [...track].reverse() : track
}
/**
* Where each stop sits on the track, as an index.
*
* Forced not to go backwards. A stop nowhere near the track still gets its nearest point,
* and on a track that doubles back that point can land before the previous stop's —
* which would put a via on the wrong leg and send the day round the loop twice.
*/
export function stopIndices(track: LatLng[], stops: LatLng[]): number[] {
let floor = 0
return stops.map(stop => {
const at = Math.max(floor, nearestIndex(track, stop))
floor = at
return at
})
}
/** The leg an anchor belongs to: the last stop it comes after. */
function legFor(anchor: number, stopIdx: number[]): number {
let leg = 0
for (let k = 0; k + 1 < stopIdx.length; k++) if (stopIdx[k] <= anchor) leg = k
return leg
}
/** The stops with the anchors threaded in between, in the order they are driven. */
function waypointsFor(stops: LatLng[], anchors: number[], track: LatLng[], stopIdx: number[]): LatLng[] {
const out: LatLng[] = []
for (let k = 0; k < stops.length; k++) {
out.push(stops[k])
if (k + 1 >= stops.length) continue
for (const anchor of anchors) if (legFor(anchor, stopIdx) === k) out.push(track[anchor])
}
return out
}
/**
* The point of the track the road misses by the most — among the ones worth fixing.
*
* Points already anchored are skipped because the router is going through them, and
* points hard against a stop or an existing via are skipped because a via there would
* buy nothing. What is left is the miss the next round can actually close.
*/
function worstMiss(
track: LatLng[],
road: LatLng[],
span: { from: number; to: number },
anchors: number[],
stops: LatLng[],
): { index: number; km: number } | null {
const taken = new Set(anchors)
let best: { index: number; km: number } | null = null
for (let i = span.from; i <= span.to; i++) {
if (taken.has(i)) continue
const point = track[i]
if (stops.some(s => haversineKm(s, point) < MIN_GAP_KM)) continue
if (anchors.some(a => haversineKm(track[a], point) < MIN_GAP_KM)) continue
const km = offLineKm(road, point)
if (!Number.isFinite(km)) continue
if (!best || km > best.km) best = { index: i, km }
}
return best
}
export interface TrackPlanInput {
/** The track's points, as stored. */
track: LatLng[]
/** The day's stops, in driving order. */
stops: LatLng[]
/** Routes through the given waypoints and answers with the road it drew. */
route: (waypoints: LatLng[]) => Promise<LatLng[]>
options?: FollowTrackOptions
/** Called after each round, so a dialog can say how far it has got. */
onRound?: (round: number, strayKm: number) => void
signal?: AbortSignal
}
/**
* How much of a day a track has anything to say about.
*
* A trip full of imported tracks needs an order to offer them in, and "how far this one
* runs from that drive" is the answer — measured from the stops rather than from the
* routed line, so it holds for a day that has not routed yet.
*/
export function trackGapKm(track: LatLng[], stops: LatLng[]): number {
if (!track.length || !stops.length) return Number.POSITIVE_INFINITY
const line = thin(track, MEASURE_POINTS)
if (line.length < 2) return haversineKm(line[0], stops[0])
let best = Number.POSITIVE_INFINITY
for (const stop of stops) best = Math.min(best, offLineKm(line, stop))
return best
}
/**
* Vias that pull a day's drive onto a track.
*
* Routes the whole day per round rather than one leg at a time: the day is a single
* request to begin with, and asking per leg would multiply every round by the number of
* legs against a host that answers one request a second.
*/
export async function planTrack({
track,
stops,
route,
options = {},
onRound,
signal,
}: TrackPlanInput): Promise<FollowTrackPlan> {
const toleranceKm = options.toleranceKm ?? 1
const maxVias = options.maxVias ?? 12
const maxRounds = options.maxRounds ?? 10
if (track.length < 2 || stops.length < 2) return EMPTY_PLAN
const measured = orient(thin(track, MEASURE_POINTS), stops)
const stopIdx = stopIndices(measured, stops)
const span = { from: stopIdx[0], to: stopIdx[stopIdx.length - 1] }
// A track the day only touches at one point is a track for some other day.
if (span.to - span.from < 2) return EMPTY_PLAN
// The legs the track runs along. Read off the stops, not off the anchors, so applying
// a track twice clears what the first pass laid down instead of stacking on it — and
// so a track that already fits still says which legs it owns.
const legs: number[] = []
for (let k = 0; k + 1 < stops.length; k++) {
if (stopIdx[k + 1] > span.from && stopIdx[k] < span.to) legs.push(k)
}
const anchors: number[] = []
let strayKm = 0
let rounds = 0
let capped = false
for (;;) {
if (signal?.aborted) break
const drawn = await route(waypointsFor(stops, anchors, measured, stopIdx))
if (drawn.length < 2) throw new Error('The router answered without a road')
rounds += 1
const miss = worstMiss(measured, thin(drawn, ROUTE_POINTS), span, anchors, stops)
// No candidate left to fix is as finished as being inside the tolerance: everything
// that remains sits against a stop or against a via that is already there.
strayKm = miss?.km ?? 0
onRound?.(rounds, strayKm)
if (!miss || miss.km <= toleranceKm) break
if (anchors.length >= maxVias || rounds >= maxRounds) {
capped = true
break
}
anchors.push(miss.index)
anchors.sort((a, b) => a - b)
}
return {
vias: anchors.map(i => ({
after_order_index: legFor(i, stopIdx),
lat: measured[i].lat,
lng: measured[i].lng,
})),
legs,
strayKm,
rounds,
capped,
}
}
@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest'
import {
reanchorAfterInsert,
reanchorAfterRemove,
reanchorByStopOrder,
reanchorAfterReorder,
isEmptyReanchoring,
type AnchoredVia,
} from './roadtripModel'
/**
* A via lives at a position in the day's stop list, and that position is resolved fresh
* every time the day is routed. These tests pin what happens to it when the list moves
* under it — the arithmetic that decides whether a reshaped drive survives adding a
* petrol station to it.
*/
const via = (id: number, after: number, lat = 53, lng = 10): AnchoredVia =>
({ id, after_order_index: after, lat, lng })
/** The anchors as a plain map, so a test reads as "which via ended up where". */
const anchors = (moved: { id: number; after_order_index: number }[]) =>
Object.fromEntries(moved.map(v => [v.id, v.after_order_index]))
describe('reanchorAfterInsert', () => {
it('FE-REANCHOR-001: a stop added past a via leaves it alone', () => {
// Legs 0,1,2 — the via is on leg 0 and the new stop lands at position 3.
const plan = reanchorAfterInsert([via(1, 0)], 3, () => true)
expect(isEmptyReanchoring(plan)).toBe(true)
})
it('FE-REANCHOR-002: a stop added before a via pushes it one leg later', () => {
const plan = reanchorAfterInsert([via(1, 2), via(2, 3)], 1, () => true)
expect(anchors(plan.vias)).toEqual({ 1: 3, 2: 4 })
expect(plan.remove).toEqual([])
})
it('FE-REANCHOR-003: on the split leg, a via before the new stop keeps its leg', () => {
// The reported bug: Hamburg → [via] → Berlin, and a fuel stop lands between them at
// position 1. The via is passed before the fuel stop, so it belongs to the first half.
const plan = reanchorAfterInsert([via(1, 0)], 1, () => true)
expect(isEmptyReanchoring(plan)).toBe(true)
})
it('FE-REANCHOR-004: on the split leg, a via past the new stop moves to the second half', () => {
// Same day, but the via sits beyond the fuel stop. Left at 0 it would bend the short
// first half back on itself and leave the long second half unshaped — which is the
// route snapping back to the old road.
const plan = reanchorAfterInsert([via(1, 0)], 1, () => false)
expect(anchors(plan.vias)).toEqual({ 1: 1 })
})
it('FE-REANCHOR-005: two vias on one split leg are told apart, not moved as a block', () => {
const early = via(1, 1)
const late = via(2, 1)
const plan = reanchorAfterInsert([early, late], 2, v => v.id === early.id)
expect(anchors(plan.vias)).toEqual({ 2: 2 })
})
it('FE-REANCHOR-006: inserting at the very front splits nothing and shifts everything', () => {
const plan = reanchorAfterInsert([via(1, 0), via(2, 1)], 0, () => {
throw new Error('nothing is split when the new stop leads the day')
})
expect(anchors(plan.vias)).toEqual({ 1: 1, 2: 2 })
})
it('FE-REANCHOR-007: appending at the end leaves every existing leg where it was', () => {
// Four stops, legs 0..2, appended as the fifth.
const plan = reanchorAfterInsert([via(1, 0), via(2, 2)], 4, () => true)
expect(isEmptyReanchoring(plan)).toBe(true)
})
it('FE-REANCHOR-008: no vias means no request', () => {
expect(isEmptyReanchoring(reanchorAfterInsert([], 2, () => true))).toBe(true)
})
})
describe('reanchorAfterRemove', () => {
it('FE-REANCHOR-009: the legs either side of a removed stop become one', () => {
// Five stops, legs 0..3. Stop 2 goes; legs 1 and 2 merge into leg 1.
const plan = reanchorAfterRemove([via(1, 1), via(2, 2), via(3, 3)], 2, 5)
expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
expect(plan.remove).toEqual([])
})
it('FE-REANCHOR-010: removing the first stop drops the vias on the leg leaving it', () => {
const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 0, 4)
expect(plan.remove).toEqual([1])
expect(anchors(plan.vias)).toEqual({ 2: 1 })
})
it('FE-REANCHOR-011: removing the last stop drops the vias on the leg into it', () => {
// Four stops, legs 0..2. Stop 3 goes, so leg 2 has nothing to lead to.
const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 3, 4)
expect(plan.remove).toEqual([2])
expect(anchors(plan.vias)).toEqual({})
})
it('FE-REANCHOR-012: a day left with one stop has no drive, so no via has a leg', () => {
const plan = reanchorAfterRemove([via(1, 0)], 1, 2)
expect(plan.remove).toEqual([1])
expect(plan.vias).toEqual([])
})
})
describe('reanchorAfterReorder', () => {
it('FE-REANCHOR-013: moving a stop later pulls the vias between along with it', () => {
// Five stops, legs 0..3. Stop 1 moves to position 3.
const plan = reanchorAfterReorder([via(1, 0), via(2, 2), via(3, 3)], 1, 3, 5)
// Legs 0 and 1 merge into 0, then the stop is put back at 3, splitting leg 2.
expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
})
it('FE-REANCHOR-014: moving a stop back to where it was changes nothing', () => {
expect(isEmptyReanchoring(reanchorAfterReorder([via(1, 1)], 2, 2, 4))).toBe(true)
})
it('FE-REANCHOR-015: a via whose leg vanishes in the move is dropped, not left dangling', () => {
// Moving the first stop kills the leg leaving it, and nothing later re-creates that
// via's anchor — reporting it for deletion is the only honest answer.
const plan = reanchorAfterReorder([via(1, 0)], 0, 2, 4)
expect(plan.remove).toEqual([1])
})
it('FE-REANCHOR-016: no vias means no request', () => {
expect(isEmptyReanchoring(reanchorAfterReorder([], 0, 2, 4))).toBe(true)
})
it('FE-REANCHOR-017: swapping the only two stops keeps every via', () => {
// A reorder removes nothing. Leg 0 is still leg 0 after the swap, merely
// driven the other way round, so the anchors are already right. Routing this
// through the removal half asked the server to delete every via of the day —
// and two stops is exactly the shape "follow this track" produces, up to
// nine vias on that one leg, gone to a single drag with no prompt and no
// undo.
const vias = [via(11, 0), via(12, 0), via(13, 0)]
for (const [from, to] of [[0, 1], [1, 0]] as const) {
const plan = reanchorAfterReorder(vias, from, to, 2)
expect(plan.remove, `${from}->${to}`).toEqual([])
expect(isEmptyReanchoring(plan), `${from}->${to}`).toBe(true)
}
})
it('FE-REANCHOR-019: a wholesale reorder follows the stop, not the number', () => {
// The plan-mode drag hands a whole new ordering rather than one move, so the
// anchors are mapped by the stop they were pinned behind. Stops A,B,C,D with
// a via on the C->D leg (anchor 2): move A to the end and C is now at index
// 1, so the via belongs on leg 1.
const plan = reanchorByStopOrder([via(1, 2)], [10, 20, 30, 40], [20, 30, 40, 10])
expect(plan.vias).toEqual([{ id: 1, after_order_index: 1 }])
expect(plan.remove).toEqual([])
})
it('FE-REANCHOR-020: a via whose stop became the last one has no leg left', () => {
// Nothing follows the final stop, so there is no drive for the via to bend.
const plan = reanchorByStopOrder([via(1, 0)], [10, 20, 30], [20, 30, 10])
expect(plan.remove).toEqual([1])
})
it('FE-REANCHOR-021: a stop that is gone takes its vias with it', () => {
const plan = reanchorByStopOrder([via(1, 1)], [10, 20, 30], [10, 30])
expect(plan.remove).toEqual([1])
})
it('FE-REANCHOR-022: an ordering that did not actually change asks for nothing', () => {
expect(isEmptyReanchoring(reanchorByStopOrder([via(1, 0)], [10, 20, 30], [10, 20, 30]))).toBe(true)
})
it('FE-REANCHOR-018: removing a stop from a two-stop day still clears the leg', () => {
// The other half of the same rule, and the reason the short-circuit exists:
// take a stop away from a two-stop day and there is no drive left, so a via
// has nowhere to sit.
const plan = reanchorAfterRemove([via(11, 0), via(12, 0)], 1, 2)
expect(plan.remove).toEqual([11, 12])
expect(plan.vias).toEqual([])
})
})
@@ -0,0 +1,547 @@
import { describe, it, expect } from 'vitest'
import {
computeSchedule,
formatClock,
formatDurationShort,
insertIndexForAlong,
deriveDriveWarnings,
legIndexForAlong,
refuelsRange,
sectionAnchors,
parseClock,
splitIntoRuns,
sumLegSeconds,
} from './roadtripModel'
describe('formatDurationShort', () => {
it('prints minutes below an hour', () => {
expect(formatDurationShort(45 * 60)).toBe('45 min')
expect(formatDurationShort(0)).toBe('0 min')
})
it('drops the zero minutes on a whole hour', () => {
expect(formatDurationShort(3600)).toBe('1 h')
expect(formatDurationShort(2 * 3600)).toBe('2 h')
})
it('prints hours and minutes together', () => {
expect(formatDurationShort(2 * 3600 + 10 * 60)).toBe('2 h 10 min')
})
it('carries instead of printing sixty minutes', () => {
// 1 h 59 min 40 s rounds the minutes to 60, which must become 2 h.
expect(formatDurationShort(3600 + 59 * 60 + 40)).toBe('2 h')
})
it('treats nonsense as nothing rather than throwing', () => {
expect(formatDurationShort(Number.NaN)).toBe('0 min')
expect(formatDurationShort(-90)).toBe('0 min')
})
})
describe('parseClock', () => {
it('reads wall-clock times', () => {
expect(parseClock('09:45')).toBe(9 * 60 + 45)
expect(parseClock('9:05')).toBe(9 * 60 + 5)
expect(parseClock('00:00')).toBe(0)
})
it('ignores anything trailing, as stored times sometimes carry seconds', () => {
expect(parseClock('14:30:00')).toBe(14 * 60 + 30)
})
it('rejects what is not a time', () => {
expect(parseClock(null)).toBeNull()
expect(parseClock('')).toBeNull()
expect(parseClock('later')).toBeNull()
expect(parseClock('25:00')).toBeNull()
expect(parseClock('12:75')).toBeNull()
})
})
describe('formatClock', () => {
it('pads both halves', () => {
expect(formatClock(9 * 60 + 5)).toBe('09:05')
expect(formatClock(0)).toBe('00:00')
})
it('wraps past midnight instead of printing a 25th hour', () => {
expect(formatClock(25 * 60)).toBe('01:00')
expect(formatClock(-30)).toBe('23:30')
})
})
describe('computeSchedule', () => {
const hours = (h: number): number => h * 3600
it('walks arrival and departure forward from the first pinned time', () => {
// The example from discussion #1797: leave at 8:00, drive 1 h 45, stay 2 h, drive 1 h 10.
const { entries } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 120 },
{ anchor: null, dwellMinutes: 45 },
],
[hours(1.75), hours(1) + 10 * 60],
)
expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '08:00', anchored: true })
expect(entries[1]).toMatchObject({ arrival: '09:45', departure: '11:45', anchored: false })
expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false })
})
it('leaves stops before the first pinned time blank rather than inventing one', () => {
const { entries } = computeSchedule(
[
{ anchor: null, dwellMinutes: 60 },
{ anchor: '10:00', dwellMinutes: 30 },
],
[hours(1)],
)
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
})
it('restarts the cascade at a pinned stop instead of pushing it', () => {
// The drive would arrive at 09:00, but the museum ticket says 11:00: everything
// after it counts from 11:00, not from the drive.
const { entries } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 0 },
{ anchor: '11:00', dwellMinutes: 60 },
{ anchor: null, dwellMinutes: 0 },
],
[hours(1), hours(1)],
)
expect(entries[1].arrival).toBe('11:00')
expect(entries[2].arrival).toBe('13:00')
})
it('ROADTRIP-MODEL-082: a night drive onto a pinned time after midnight is on time', () => {
// Leave at 20:00, drive six hours, check in at 02:00. The anchor used to be
// placed on the day the cascade had reached so far — and that only advances
// once a computed arrival crosses midnight, which is read before this stop's
// arrival is known. So the stop that does the crossing had its own pin put a
// whole day early: the rail drew a warning-coloured "+24 h" on a plan that
// was exactly on time, and dropped the overnight marker with it.
const { entries, warnings } = computeSchedule(
[
{ anchor: '20:00', dwellMinutes: 0 },
{ anchor: '02:00', dwellMinutes: 0 },
],
[hours(6)],
)
expect(warnings.filter(w => w.code === 'late')).toEqual([])
expect(entries[1]).toMatchObject({ arrival: '02:00', dayOffset: 1, anchored: true })
// The day still turned over, so the rail keeps its carry badge.
expect(warnings.some(w => w.code === 'overnight' && w.index === 1)).toBe(true)
})
it('ROADTRIP-MODEL-083: a ferry that really is missed is still reported late', () => {
// The other half: picking the nearest occurrence must not swallow a genuine
// delay. Leaving at 21:00 and driving five hours arrives at 02:00, half an
// hour after the 01:30 ferry, and that is what it says.
const { warnings } = computeSchedule(
[
{ anchor: '21:00', dwellMinutes: 0 },
{ anchor: '01:30', dwellMinutes: 0 },
],
[hours(5)],
)
expect(warnings.find(w => w.code === 'late')).toMatchObject({ index: 1, minutes: 30 })
})
it('ROADTRIP-MODEL-084: a pinned stop after an untimed leg keeps the day it is on', () => {
// Nothing to be late against, but the day carried so far still applies —
// otherwise the stop prints under a day-1 stop as though it happened first.
const { entries } = computeSchedule(
[
{ anchor: '20:00', dwellMinutes: 0 },
{ anchor: '02:00', dwellMinutes: 0 },
{ anchor: '09:00', dwellMinutes: 0 },
],
[hours(6), undefined],
)
expect(entries[2]).toMatchObject({ arrival: '09:00', dayOffset: 1 })
})
it('reports a stop the drive cannot reach in time', () => {
const { warnings } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 0 },
{ anchor: '09:00', dwellMinutes: 0 },
],
[hours(3)],
)
expect(warnings).toEqual([{ index: 1, code: 'late', minutes: 120 }])
})
it('does not cry about a minute of rounding', () => {
const { warnings } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 0 },
{ anchor: '09:00', dwellMinutes: 0 },
],
[hours(1) + 30],
)
expect(warnings).toEqual([])
})
it('breaks the chain at a leg that never routed', () => {
const { entries } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 30 },
{ anchor: null, dwellMinutes: 30 },
{ anchor: null, dwellMinutes: 0 },
],
[undefined, hours(1)],
)
expect(entries[0].arrival).toBe('08:00')
expect(entries[1].arrival).toBeNull()
expect(entries[2].arrival).toBeNull()
})
it('flags the day rolling past midnight and keeps counting', () => {
const { entries, warnings } = computeSchedule(
[
{ anchor: '22:00', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 0 },
],
[hours(4)],
)
expect(entries[1].arrival).toBe('02:00')
expect(entries[1].dayOffset).toBe(1)
expect(warnings).toEqual([{ index: 1, code: 'overnight' }])
})
it('handles an empty chain', () => {
expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] })
})
it('treats a missing dwell as no time spent', () => {
const { entries } = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: null },
{ anchor: null, dwellMinutes: null },
],
[hours(1)],
)
expect(entries[0].departure).toBe('08:00')
expect(entries[1].arrival).toBe('09:00')
})
})
describe('splitIntoRuns', () => {
const drive = (): string => 'driving'
it('asks for a whole day travelled one way in a single run', () => {
const runs = splitIntoRuns(['a', 'b', 'c', 'd'], drive)
expect(runs).toEqual([{ stops: ['a', 'b', 'c', 'd'], mode: 'driving' }])
})
it('splits where the travel mode changes, and repeats the stop on both sides', () => {
// Drive to b, walk to c, drive on to d: the walk is its own request, and b and c
// each belong to two runs because they are the ends of neighbouring legs.
const modes = ['driving', 'walking', 'driving']
const runs = splitIntoRuns(['a', 'b', 'c', 'd'], (from) => modes[['a', 'b', 'c'].indexOf(from)])
expect(runs).toEqual([
{ stops: ['a', 'b'], mode: 'driving' },
{ stops: ['b', 'c'], mode: 'walking' },
{ stops: ['c', 'd'], mode: 'driving' },
])
})
it('keeps consecutive legs of the same mode together across a change and back', () => {
const modes = ['driving', 'driving', 'walking', 'walking']
const runs = splitIntoRuns(['a', 'b', 'c', 'd', 'e'], (from) => modes[['a', 'b', 'c', 'd'].indexOf(from)])
expect(runs).toEqual([
{ stops: ['a', 'b', 'c'], mode: 'driving' },
{ stops: ['c', 'd', 'e'], mode: 'walking' },
])
})
it('has nothing to ask for when there is no leg', () => {
expect(splitIntoRuns([], drive)).toEqual([])
expect(splitIntoRuns(['a'], drive)).toEqual([])
})
})
describe('sumLegSeconds', () => {
it('adds only the legs that routed', () => {
expect(sumLegSeconds([600, undefined, 1200])).toBe(1800)
expect(sumLegSeconds([])).toBe(0)
})
})
describe('insertIndexForAlong', () => {
// Hamburg 0, Lueneburg 50, Berlin 290 — the day's stops as distances driven.
const stops = [0, 50, 290]
it('puts a hit between the two stops it falls between', () => {
expect(insertIndexForAlong(stops, 120)).toBe(2)
expect(insertIndexForAlong(stops, 20)).toBe(1)
})
it('never lands before the stop the day starts from', () => {
expect(insertIndexForAlong(stops, 0)).toBe(1)
// A corridor is wider than the road, so a hit can project just behind the start.
expect(insertIndexForAlong(stops, -3)).toBe(1)
})
it('never lands after the stop the day ends at', () => {
expect(insertIndexForAlong(stops, 290)).toBe(2)
expect(insertIndexForAlong(stops, 400)).toBe(2)
})
it('puts a hit exactly on a stop after it, not before', () => {
expect(insertIndexForAlong(stops, 50)).toBe(2)
})
it('has nowhere to insert on a day that is not a drive', () => {
expect(insertIndexForAlong([], 10)).toBe(0)
expect(insertIndexForAlong([0], 10)).toBe(1)
})
})
describe('legIndexForAlong', () => {
// Against the legs' own lengths rather than by projecting the stops as well: leg
// distances come from the router and are exact, while two stops close together project
// onto each other's stretch and would put a charging halt the wrong side of a town.
const legs = [10_000, 25_000, 40_000]
it('FE-ROADTRIP-MODEL-050: a point inside a leg belongs to that leg', () => {
expect(legIndexForAlong(legs, 0)).toBe(0)
expect(legIndexForAlong(legs, 9_999)).toBe(0)
expect(legIndexForAlong(legs, 10_000)).toBe(1)
expect(legIndexForAlong(legs, 24_999)).toBe(1)
expect(legIndexForAlong(legs, 25_000)).toBe(2)
})
it('FE-ROADTRIP-MODEL-051: a point past the end lands in the last leg, not nowhere', () => {
// A rounding edge, not a missing leg: the projection and the router measure the same
// line two different ways and disagree by metres at the very end.
expect(legIndexForAlong(legs, 40_000)).toBe(2)
expect(legIndexForAlong(legs, 99_999)).toBe(2)
})
it('FE-ROADTRIP-MODEL-052: a day with no routed leg has nowhere to put anything', () => {
expect(legIndexForAlong([], 100)).toBe(-1)
})
})
describe('deriveDriveWarnings', () => {
// Worked through the way a traveller reads it: Hamburg, A1, HEM, Berlin. The badge on
// a stop has to agree with the sum of the drive bands above it, or it looks made up.
it('FE-ROADTRIP-MODEL-069: the figure on a stop is what the bands above it add up to', () => {
const out = deriveDriveWarnings(
[leg(20, 21.5), leg(62, 103.4), leg(155, 244.7)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
0,
)
// 21.5 + 103.4 on arrival at HEM, and then it keeps counting, because nothing on this
// trip fills up.
expect(out.warnings).toEqual([
{ index: 2, code: 'range', sinceKm: 125 },
{ index: 3, code: 'range', sinceKm: 370 },
])
})
it('FE-ROADTRIP-MODEL-070: arriving at a petrol station is the plan, not a warning', () => {
// Same trip, but HEM is marked as fuel. Reaching it with an empty tank is exactly
// what it is there for, so nothing is flagged on it; the budget still starts over,
// and the next stretch is measured from there.
const out = deriveDriveWarnings(
[leg(20, 21.5), leg(62, 103.4), leg(155, 244.7)],
[false, false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
0,
)
expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 245 }])
// 245 and not 370: HEM filled the tank, so Berlin counts from there.
})
const noLimits = { legMinutes: null, dayMinutes: null, rangeKm: null }
const leg = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000 })
it('FE-ROADTRIP-MODEL-060: no limits set means nothing to report', () => {
const out = deriveDriveWarnings([leg(600, 900)], [false, false], noLimits, 0)
expect(out.warnings).toEqual([])
expect(out.day).toBeNull()
})
it('FE-ROADTRIP-MODEL-061: a leg over the limit reports how far over, on the stop it arrives at', () => {
// On arrival, not on departure. Both findings are running totals that only become
// true when you get there, and anchored to the departure the number reads as the
// length of the NEXT leg, which is a different figure and the one that looked wrong.
const out = deriveDriveWarnings(
[leg(100, 90), leg(240, 200)],
[false, false, false],
{ ...noLimits, legMinutes: 180 },
0,
)
expect(out.warnings).toEqual([{ index: 2, code: 'leg', overMinutes: 60 }])
})
it('FE-ROADTRIP-MODEL-062: the day figure is the sum of the legs, and only that', () => {
const out = deriveDriveWarnings(
[leg(200, 180), leg(200, 180)],
[false, false, false],
{ ...noLimits, dayMinutes: 360 },
0,
)
expect(out.day).toEqual({ code: 'dayDriving', minutes: 400, limitMinutes: 360 })
})
it('FE-ROADTRIP-MODEL-063: the figure keeps counting until something actually refuels', () => {
// A warning is not a fill-up. Zeroing the budget at one made every figure after the
// first wrong: the second stop would claim a fresh tank while the same one is still
// in the car.
const out = deriveDriveWarnings(
[leg(60, 300), leg(60, 400), leg(60, 300), leg(60, 400)],
[false, false, false, false, false],
{ ...noLimits, rangeKm: 600 },
0,
)
expect(out.warnings).toEqual([
{ index: 2, code: 'range', sinceKm: 700 },
{ index: 3, code: 'range', sinceKm: 1000 },
{ index: 4, code: 'range', sinceKm: 1400 },
])
})
it('FE-ROADTRIP-MODEL-071: adding a fuel stop is what stops the run of warnings', () => {
// The same drive with a charger at stop 2. Nothing is flagged there, because reaching
// it on an empty tank is what it is for; everything after counts from zero again, so
// the three-warning run above collapses to one.
const out = deriveDriveWarnings(
[leg(60, 300), leg(60, 400), leg(60, 300), leg(60, 400)],
[false, false, true, false, false],
{ ...noLimits, rangeKm: 600 },
0,
)
expect(out.warnings).toEqual([{ index: 4, code: 'range', sinceKm: 700 }])
})
it('FE-ROADTRIP-MODEL-064: filling up starts the budget over, resting does not', () => {
const stops = [false, true, false]
const out = deriveDriveWarnings(
[leg(60, 500), leg(60, 500)],
stops,
{ ...noLimits, rangeKm: 600 },
0,
)
// The tank is filled at stop 1, so the second 500 km starts from zero and neither leg
// trips the limit. Without the refuel it would be 1000 km on a 600 km range.
expect(out.warnings).toEqual([])
})
it('FE-ROADTRIP-MODEL-065: only fuel and charging refuel', () => {
expect(refuelsRange('fuel')).toBe(true)
expect(refuelsRange('charging')).toBe(true)
// A two-hour lunch fills no tank, and TREK does not know whether the restaurant has a
// charger in its car park.
expect(refuelsRange('restaurant')).toBe(false)
expect(refuelsRange('rest_area')).toBe(false)
expect(refuelsRange('campsite')).toBe(false)
expect(refuelsRange(null)).toBe(false)
})
it('FE-ROADTRIP-MODEL-066: the budget carries into the next day, because a tank does not empty overnight', () => {
const first = deriveDriveWarnings([leg(60, 400)], [false, false], { ...noLimits, rangeKm: 600 }, 0)
expect(first.carryKm).toBe(400)
const second = deriveDriveWarnings([leg(60, 300)], [false, false], { ...noLimits, rangeKm: 600 }, first.carryKm)
expect(second.warnings).toEqual([{ index: 1, code: 'range', sinceKm: 700 }])
})
it('FE-ROADTRIP-MODEL-067: an unrouted leg gives the budget up rather than guessing', () => {
const out = deriveDriveWarnings(
[undefined, leg(60, 500)],
[false, false, false],
{ ...noLimits, rangeKm: 100 },
0,
)
expect(out.warnings).toEqual([])
expect(out.carryKm).toBeNull()
})
it('FE-ROADTRIP-MODEL-068: filling up at the last stop of the day still counts', () => {
const out = deriveDriveWarnings([leg(60, 400)], [false, true], { ...noLimits, rangeKm: 600 }, 0)
expect(out.carryKm).toBe(0)
})
})
describe('sectionAnchors', () => {
it('FE-ROADTRIP-MODEL-080: every stop, and the middle of every leg between them', () => {
// The leg midpoints are the point of the feature: a break on a five-hour drive is
// planned in the middle of the drive, not at either end of it.
expect(sectionAnchors([0, 100, 250])).toEqual([
{ kind: 'stop', index: 0, alongKm: 0 },
{ kind: 'leg', index: 0, alongKm: 50 },
{ kind: 'stop', index: 1, alongKm: 100 },
{ kind: 'leg', index: 1, alongKm: 175 },
{ kind: 'stop', index: 2, alongKm: 250 },
])
})
it('FE-ROADTRIP-MODEL-081: a single stop has no leg to sit in the middle of', () => {
expect(sectionAnchors([42])).toEqual([{ kind: 'stop', index: 0, alongKm: 42 }])
expect(sectionAnchors([])).toEqual([])
})
})
describe('deriveDriveWarnings — what counts as driving', () => {
const drive = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
const onFoot = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'walking' })
it('FE-ROADTRIP-MODEL-085: a walk to a viewpoint is not over the longest drive allowed', () => {
// A day may legitimately mix modes — splitIntoRuns exists for exactly that —
// and a two hour hike reported as a leg finding reads as a fault in a plan
// that has none.
const out = deriveDriveWarnings(
[drive(60, 80), onFoot(120, 6)],
[false, false, false],
{ legMinutes: 90, dayMinutes: null, rangeKm: null },
0,
)
expect(out.warnings).toEqual([])
})
it('FE-ROADTRIP-MODEL-086: walked kilometres are not on the tank', () => {
// Nor does walking fill it: the budget is left exactly as the drive left it.
const out = deriveDriveWarnings(
[drive(60, 80), onFoot(120, 60), drive(30, 30)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
0,
)
// 80 then 110 — the 60 walked kilometres never entered the budget.
expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 110 }])
expect(out.carryKm).toBe(110)
})
it('FE-ROADTRIP-MODEL-087: a walk does not count toward the driving time of the day', () => {
const out = deriveDriveWarnings(
[drive(60, 80), onFoot(180, 9)],
[false, false, false],
{ legMinutes: null, dayMinutes: 90, rangeKm: null },
0,
)
expect(out.day).toBeNull()
})
it('FE-ROADTRIP-MODEL-088: a leg with no mode is still a drive', () => {
// Every leg was a drive before the field existed, so an absent mode counts.
// Reading it the other way round would silently switch the warnings off.
const out = deriveDriveWarnings(
[{ duration: 120 * 60, distance: 200 * 1000 }],
[false, false],
{ legMinutes: 90, dayMinutes: null, rangeKm: null },
0,
)
expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }])
})
})
@@ -0,0 +1,641 @@
/**
* Pure road-trip arithmetic — no React, no network, no store. Everything here is a
* function of the stops, the routed legs and the day's rules, so it can be unit tested
* on its own and reused by the map, the rail and (later) the MCP tool.
*/
/**
* The stop kinds that interrupt a drive rather than end it.
*
* Every kind the corridor search can find, and the list is meant to stay that way: what
* you pick off "along the route" is by definition something you come across on the way,
* so it belongs inside the leg it was found on rather than among the places the trip is
* for. They are drawn on the dashed line with their own icon, carry no number, and are
* left out of every stop count — four places with a charger, a rest stop and a bakery
* between them is a four-stop day.
*
* Extended together with the corridor's own categories (`RoadtripCorridorPanel`) and the
* enum in `@trek/shared`; a kind in one list and not the others is a stop that looks like
* a destination on one screen and a pause on the next.
*/
export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const
/**
* What each kind of pause is coloured, on the rail, in the corridor list and on the map.
*
* Read as road signage rather than as a palette: a pump is petrol orange, a charger is
* the yellow of electricity, a rest area takes the blue every parking sign in Europe is
* printed in, a campsite is green. The last two are the colours the general place search
* already gives food and sights, so a restaurant found along the route looks like the
* restaurants found anywhere else.
*
* Deliberately literal and shared rather than tokenised: these mean the same thing in
* both themes the way a motorway sign does, and one definition is what stops the rail,
* the search result and the map pin from drifting into three different reds.
*/
export const SERVICE_COLORS: Record<string, string> = {
fuel: '#E8590C',
charging: '#CA8A04',
rest_area: '#3B82F6',
campsite: '#16A34A',
restaurant: '#EF4444',
sights: '#EC4899',
}
/** The colour for a kind, or the neutral one for a kind nobody has a colour for yet. */
export function serviceColor(stopType: string | null | undefined): string {
return SERVICE_COLORS[stopType ?? ''] ?? '#64748B'
}
/** Whether a stop breaks the drive. The rail's shape and every count turn on this. */
export function isServiceStopType(stopType: string | null | undefined): boolean {
return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType)
}
/** Seconds → "2 h 10 min" / "45 min", matching the wording of the map's route connectors. */
export function formatDurationShort(seconds: number): string {
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
const h = Math.floor(safe / 3600)
const m = Math.round((safe % 3600) / 60)
// 59.6 min must not print as "60 min"; carry it.
if (m === 60) return `${h + 1} h`
if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`
return `${m} min`
}
/** "09:45" → 585. Anything that is not a wall-clock time returns null. */
export function parseClock(value: string | null | undefined): number | null {
if (!value) return null
const m = /^(\d{1,2}):(\d{2})/.exec(value.trim())
if (!m) return null
const h = Number(m[1])
const min = Number(m[2])
if (h > 23 || min > 59) return null
return h * 60 + min
}
/** 585 → "09:45". Minutes past midnight beyond a day wrap around and report the carry. */
export function formatClock(minutes: number): string {
const day = 24 * 60
const wrapped = ((minutes % day) + day) % day
const h = Math.floor(wrapped / 60)
const m = Math.round(wrapped % 60)
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
export interface ScheduleStop {
/** Wall-clock time the user pinned on this stop, if any. */
anchor: string | null
/** Planned time spent here, in minutes. */
dwellMinutes: number | null
}
export interface ScheduleWarning {
/**
* Index of the stop the finding belongs to.
*
* For the two leg codes this is the stop the leg ARRIVES AT, not the one it leaves.
* Both findings are a running total that only becomes true on arrival: "125 km since
* the last fill-up" is what the tank reads when you get there, and the figure has to
* agree with the one anybody adds up off the drive bands above it. Anchored to the
* departure it read as the distance of the leg leaving that stop, which is a different
* number entirely and the one that made the badge look wrong.
*/
index: number
code: 'late' | 'overnight' | 'leg' | 'range'
/** Minutes the computed arrival misses the anchor by (code 'late'). */
minutes?: number
/** Minutes this leg runs over the longest single drive allowed (code 'leg'). */
overMinutes?: number
/** Kilometres driven since the last fill-up when the range ran out (code 'range'). */
sinceKm?: number
}
/**
* The two kinds of stop that put fuel or charge back.
*
* Deliberately shorter than `SERVICE_STOP_TYPES`: a two-hour lunch fills no tank, and
* TREK knows nothing about whether the restaurant has a charger in its car park. Reusing
* the service list here would quietly reset the range budget at every rest area and hide
* the one finding this feature exists to produce.
*/
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
/** Whether stopping here starts the range budget over. */
export function refuelsRange(stopType: string | null | undefined): boolean {
return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '')
}
/**
* The three numbers a traveller can set. null means no limit, which is an explicit off
* rather than a sentinel: zero is a legal thing to type and means the same as unset.
*/
export interface DriveLimits {
/** Longest single drive between two stops, in minutes. */
legMinutes: number | null
/** Longest total driving in one day, in minutes. */
dayMinutes: number | null
/** How far one tank or charge goes, in kilometres. */
rangeKm: number | null
}
/**
* A finding about the day as a whole.
*
* Its own shape rather than a ScheduleWarning with index -1: a sentinel index would be
* read by every consumer that filters findings by stop, and one of them would eventually
* draw the day's finding on the first stop.
*/
export interface DayWarning {
code: 'dayDriving'
/** Driving time of the day in minutes, the same number the badge beside it shows. */
minutes: number
limitMinutes: number
}
export interface ScheduleEntry {
/** Computed arrival, or the anchor where the user pinned one. */
arrival: string | null
departure: string | null
/** True when the time was pinned by the user rather than derived. */
anchored: boolean
/** Days past the first stop, so a chain running past midnight stays readable. */
dayOffset: number
}
export interface Schedule {
entries: ScheduleEntry[]
warnings: ScheduleWarning[]
}
/**
* Walks the chain forward: arrival = previous departure + driving time, departure =
* arrival + time spent at the stop. A stop the user pinned a time on does not move —
* it restarts the cascade from its own time, which is what makes a ferry, a museum
* ticket or a hotel check-in usable as a fixed point (Furkot solves it the same way).
*
* `legSeconds[i]` is the drive from stop i to stop i+1; a leg that never routed is
* `undefined` and breaks the chain rather than inventing a duration.
*/
/**
* Where a stop's clock actually starts.
*
* A pinned time wins over whatever the drive before it worked out and restarts the
* cascade from there. When it sits before the arrival the drive implies, the plan does
* not fit: the caller is told by how much rather than the pinned time being moved.
*/
const DAY_MINUTES = 24 * 60
function resolveArrival(
anchor: number | null,
cursor: number | null,
dayOffset: number,
): { arrival: number | null; lateBy: number | null } {
if (anchor === null) return { arrival: cursor, lateBy: null }
// No drive has been timed yet, so there is nothing to be late against — but the
// day carried so far still applies, or a pinned stop after an untimed leg
// prints under a day-1 stop as though it happened first.
if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null }
// The occurrence of that wall-clock time nearest the cursor, not the one on the
// day the cascade has reached so far. `dayOffset` only advances once a computed
// arrival crosses midnight, and it is read before this stop's arrival is known —
// so when the stop that crosses midnight is the pinned one, its anchor was
// placed a whole day early and the plan was reported as 24 hours late while
// being exactly on time. Rounding also keeps the ordinary case: 08:00, a three
// hour leg, a 09:00 pin still picks the same day and is two hours late.
const k = Math.round((cursor - anchor) / DAY_MINUTES)
const anchorAt = anchor + k * DAY_MINUTES
return {
arrival: anchorAt,
lateBy: cursor > anchorAt + 1 ? Math.round(cursor - anchorAt) : null,
}
}
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
const entries: ScheduleEntry[] = []
const warnings: ScheduleWarning[] = []
// Minutes since the first stop's midnight, so a chain crossing midnight keeps counting.
let cursor: number | null = null
let dayOffset = 0
for (let i = 0; i < stops.length; i++) {
const stop = stops[i]
const anchor = parseClock(stop.anchor)
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset)
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy })
if (arrival === null) {
entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 })
continue
}
const offset = Math.floor(arrival / (24 * 60))
if (offset > dayOffset) {
dayOffset = offset
warnings.push({ index: i, code: 'overnight' })
}
const dwell = stop.dwellMinutes ?? 0
const departure = arrival + dwell
entries.push({
arrival: formatClock(arrival),
departure: formatClock(departure),
anchored: anchor !== null,
dayOffset: offset,
})
const leg = legSeconds[i]
cursor = leg === undefined ? null : departure + Math.round(leg / 60)
}
return { entries, warnings }
}
/**
* Splits a day's stops into the stretches that can be asked for in one routing request:
* consecutive stops whose leg shares a travel mode.
*
* The router returns a leg for every consecutive pair of the waypoints it is handed, so
* a day travelled one way is a single request rather than one per leg — the difference
* between a road trip appearing at once and crawling in over ten seconds. A day that
* mixes walking and driving splits at the change, because one request carries one mode.
*/
export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {
const runs: { stops: T[]; mode: string }[] = []
let current: T[] = []
let currentMode: string | null = null
for (let i = 0; i < stops.length - 1; i++) {
const mode = modeOfLeg(stops[i], stops[i + 1])
if (currentMode === null) {
current = [stops[i], stops[i + 1]]
currentMode = mode
} else if (mode === currentMode) {
current.push(stops[i + 1])
} else {
runs.push({ stops: current, mode: currentMode })
current = [stops[i], stops[i + 1]]
currentMode = mode
}
}
if (currentMode !== null && current.length > 1) runs.push({ stops: current, mode: currentMode })
return runs
}
/** Total driving seconds of the legs that actually routed. */
export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
return legSeconds.reduce<number>((sum, s) => sum + (s ?? 0), 0)
}
/**
* Where a place found along the drive belongs in the day's chain.
*
* Both numbers are distances travelled along the same routed line, so the comparison is
* "which stops has the car already passed when it reaches this one". A petrol station
* 120 km into the day goes after every stop nearer than that and before the rest,
* which is what makes adding one a single click rather than a drag afterwards.
*
* Clamped to sit inside the day: a corridor is wider than the road, so a hit can project
* slightly before the first stop or past the last, and neither "before where you set off"
* nor "after where you arrive" is a place a fuel stop can go.
*/
/**
* Which leg a point on the drive belongs to, given where each leg ends.
*
* Measured against the legs' own lengths rather than by projecting the stops as well: the
* router's leg distances are exact by construction, while two stops close together
* project onto each other's stretch and would put a charging halt on the wrong side of a
* town. A point past the end lands in the last leg rather than nowhere, because that is
* a rounding edge and not a missing leg.
*/
/**
* The findings about the driving itself: too long at the wheel, too long in one day, and
* the tank running out before anywhere to fill it.
*
* Reads the legs and nothing else. The clock cascade is not available to lean on: with no
* stop pinned to a time it produces no times at all, for any stop, so a limit expressed as
* an hour of the day would simply never fire. All three limits are therefore durations and
* kilometres, which exist as soon as a leg has routed.
*
* The range budget resets at a fuel or charging stop and NOWHERE ELSE. It carries across
* midnight, because a tank does not empty overnight, and it keeps counting past a finding,
* because a warning is not a fill-up. Zeroing it there made the second figure wrong and
* every one after it: a day reading 245 km at one stop and 121 at the next claims the 121
* is a fresh tank, when it is 366 on the same one. A run of warnings down a stretch with
* no fuel on it is not noise; it is the answer, and it stops the moment a fuel or
* charging stop is put in.
*
* An unrouted leg gives the budget up entirely rather than guessing, the same way the
* schedule abandons its cursor: a distance we do not have cannot be added to one we do.
*/
export function deriveDriveWarnings(
/**
* `mode` decides whether a leg counts at all. A day may legitimately mix
* modes — splitIntoRuns exists for exactly that — and a two hour walk to a
* viewpoint is not two hours of driving, nor are its kilometres on the tank.
* Counting them reported a hike as "over the longest drive allowed" and let a
* ferry crossing trip a range warning. Anything that is not a driving profile
* is skipped; an absent mode counts, because every leg was a drive before the
* field existed.
*/
legs: ({ duration?: number; distance?: number; mode?: string } | undefined)[],
/** Whether the stop at each index refuels. One entry longer than `legs`. */
refuelsAt: boolean[],
limits: DriveLimits,
/** Kilometres already on the tank when the day starts; null when that is unknown. */
carryKm: number | null,
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } {
const warnings: ScheduleWarning[] = []
let budget = carryKm
let totalSeconds = 0
for (let i = 0; i < legs.length; i++) {
if (refuelsAt[i]) budget = 0
const leg = legs[i]
// The stop this leg arrives at. Both findings are about what is true on arrival.
const at = i + 1
// Not a drive: neither its minutes nor its kilometres belong in findings
// about the driving. The budget is left exactly as it was — walking to a
// viewpoint and back does not use fuel, and it does not fill the tank
// either.
if (leg && leg.mode !== undefined && leg.mode !== 'driving') continue
const seconds = leg?.duration
if (typeof seconds === 'number') {
totalSeconds += seconds
if (limits.legMinutes && seconds / 60 > limits.legMinutes) {
warnings.push({ index: at, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) })
}
}
const metres = leg?.distance
if (typeof metres !== 'number') {
budget = null
} else if (budget !== null) {
budget += metres / 1000
// Not on a stop that fills up: arriving at a petrol station with an empty tank is
// the plan working, not a problem, and a warning there would sit on the one stop
// that answers it. No reset either — a warning is not a fill-up, and the figure has
// to keep counting until a fuel or charging stop actually puts one in.
if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) {
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
}
}
}
// The last stop of the day counts too: filling up on arrival is what makes the next
// morning start with a full tank.
if (refuelsAt[legs.length]) budget = 0
const minutes = Math.round(totalSeconds / 60)
const day = limits.dayMinutes && minutes > limits.dayMinutes
? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes }
: null
return { warnings, day, carryKm: budget }
}
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
if (!legEndMeters.length) return -1
for (let i = 0; i < legEndMeters.length; i++) {
if (alongMeters < legEndMeters[i]) return i
}
return legEndMeters.length - 1
}
/**
* The places on a day somebody would plan a break around.
*
* Every stop, and the middle of every leg between two of them. The leg midpoints are the
* point of the whole thing: a break on a five-hour drive is planned in the middle of the
* drive, not at either end of it, and offering only the stops would mean asking "what is
* near Berlin" when the question is "what is near the halfway mark".
*/
export interface SectionAnchor {
kind: 'stop' | 'leg'
/** Index of the stop, or of the stop the leg leaves. */
index: number
alongKm: number
}
export function sectionAnchors(stopsAlongKm: number[]): SectionAnchor[] {
const out: SectionAnchor[] = []
for (let i = 0; i < stopsAlongKm.length; i++) {
out.push({ kind: 'stop', index: i, alongKm: stopsAlongKm[i] })
if (i + 1 < stopsAlongKm.length) {
out.push({ kind: 'leg', index: i, alongKm: (stopsAlongKm[i] + stopsAlongKm[i + 1]) / 2 })
}
}
return out
}
export function insertIndexForAlong(stopsAlongKm: number[], hitAlongKm: number): number {
if (stopsAlongKm.length < 2) return stopsAlongKm.length
let i = 0
while (i < stopsAlongKm.length && stopsAlongKm[i] <= hitAlongKm) i++
return Math.min(Math.max(i, 1), stopsAlongKm.length - 1)
}
/* ── Via re-anchoring ────────────────────────────────────────────────────────
*
* A via records which stop it follows as a POSITION in the day's stop list, and that
* position is resolved fresh every time the day is routed. So the moment the list
* changes shape — a stop inserted, removed, reordered, pushed to another day — every
* anchor at or past the change means a different leg than it did when it was written.
* The visible symptom is the route quietly snapping back to the road the user had
* steered it away from, with the via handle still sitting on the map where they left it.
*
* Nothing about that is repairable after the fact: once the stops have moved there is no
* record of which leg a via was drawn for. So the correction has to be computed against
* the list as it stands and applied with the same change.
*
* These are pure index arithmetic on purpose. The one judgement that needs geometry —
* which half of a split leg a via belongs to — is asked of the caller, because only the
* caller has the routed line to measure against.
*/
/**
* A via, reduced to what re-anchoring needs to know about it.
*
* The coordinates are part of it because the one decision that is not pure arithmetic —
* which half of a split leg a via belongs to — is answered by measuring where it sits on
* the road, and the caller is handed the whole via to measure.
*/
export interface AnchoredVia {
id: number
after_order_index: number
lat: number
lng: number
}
/** Where one via ends up. */
export interface ReanchoredVia {
id: number
after_order_index: number
}
/**
* The corrected anchoring, and the vias that no longer have a leg at all.
*
* `vias` holds only what actually moved: sending the unchanged ones would be a larger
* write for no reason, and it keeps the diff readable when something goes wrong.
*/
export interface Reanchoring {
vias: ReanchoredVia[]
remove: number[]
}
const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] }
/** Only what moved, so an operation that changes nothing costs no request. */
function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring {
const moved: ReanchoredVia[] = []
const remove: number[] = []
for (const via of vias) {
const next = at(via.after_order_index)
if (next === null) remove.push(via.id)
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
}
return { vias: moved, remove }
}
/**
* A stop was inserted at `position`, so everything from there on sits one place later.
*
* The leg the new stop lands in is SPLIT in two, and its vias have to be told apart: one
* before the new stop stays on the first half, one after it moves to the second. That is
* the whole reason a petrol station added in the middle of a reshaped drive used to undo
* the reshaping — the via went to the first half by accident of arithmetic, the second
* half got none, and the router took its own road again for the rest of the leg.
*
* `liesBeforeInsert` is asked only about vias on the split leg, and only when there is
* one: inserting at the very front or the very end splits nothing.
*/
export function reanchorAfterInsert(
vias: AnchoredVia[],
position: number,
liesBeforeInsert: (via: AnchoredVia) => boolean,
): Reanchoring {
if (!vias.length) return EMPTY_REANCHORING
const split = position - 1
const moved: ReanchoredVia[] = []
for (const via of vias) {
const i = via.after_order_index
let next = i
if (i === split) next = liesBeforeInsert(via) ? i : i + 1
else if (i >= position) next = i + 1
if (next !== i) moved.push({ id: via.id, after_order_index: next })
}
return { vias: moved, remove: [] }
}
/**
* A stop was taken out at `position`, so the legs either side of it become one.
*
* A via on either half keeps bending the same drive, so both end up on the merged leg —
* dropping them would throw away a decision the user made about a road that still exists.
* The exception is the ends: remove the first stop and the leg leaving it is gone, remove
* the last and the leg into it is gone, and a via pinned to one of those has nowhere left
* to sit. Those are reported for deletion rather than left pointing past the day.
*
* `stopCount` is the number of stops BEFORE the removal.
*/
export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring {
if (!vias.length) return EMPTY_REANCHORING
// Two stops become one: the day has no drive left, so no via has a leg.
if (stopCount <= 2) return { vias: [], remove: vias.map(v => v.id) }
return collect(vias, i => {
if (position === 0) return i === 0 ? null : i - 1
if (position === stopCount - 1) return i === position - 1 ? null : i
if (i === position) return position - 1
return i > position ? i - 1 : i
})
}
/**
* A stop moved from `from` to `to` inside its own day.
*
* Composed from the two operations it actually is — taken out, put back — because that
* is the only reading under which the answer stays consistent with the other two. No
* geometry is asked for: a reorder changes which stops are neighbours, so the roads
* themselves are about to be different and there is nothing meaningful to measure a via
* against. Vias keep the earlier half of a split leg, which is the reading that leaves
* them where they were relative to the stop they were drawn after.
*
* `stopCount` is the number of stops BEFORE the move. `to` is the index in the list as
* it looks AFTER the stop has been taken out.
*/
/**
* Re-pin a day's vias after its stops were reordered wholesale.
*
* The single-move helpers above take a from/to pair, which is what the rail
* hands them. The plan-mode drag hands a whole new ordering instead, and any
* permutation is possible — so this maps by the stop each anchor refers to
* rather than by arithmetic on the index. A via anchored after a stop stays
* anchored after that same stop, wherever it ended up.
*
* Both lists are in the rail's index space, i.e. only stops that can be routed.
* A via whose stop is gone, or which lands on the last stop and so has no leg
* left to bend, is reported for removal.
*/
export function reanchorByStopOrder(
vias: AnchoredVia[],
previousIds: number[],
nextIds: number[],
): Reanchoring {
if (!vias.length) return EMPTY_REANCHORING
const moved: ReanchoredVia[] = []
const remove: number[] = []
for (const via of vias) {
const stopId = previousIds[via.after_order_index]
const next = stopId === undefined ? -1 : nextIds.indexOf(stopId)
// Gone, or now the last stop of the day: either way there is no leg for it.
if (next === -1 || next >= nextIds.length - 1) {
remove.push(via.id)
continue
}
if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
}
return { vias: moved, remove }
}
export function reanchorAfterReorder(
vias: AnchoredVia[],
from: number,
to: number,
stopCount: number,
): Reanchoring {
if (!vias.length || from === to) return EMPTY_REANCHORING
// A reorder is modelled as remove-then-insert, and the removal half refuses to
// treat a two-stop day as still having a drive: below three stops it reports
// every via for deletion, which is right when a stop really goes away and
// catastrophic here. Nothing is removed by a reorder — swapping the only two
// stops leaves leg 0 exactly where it was, merely driven the other way round —
// so the anchors are already correct and there is nothing to re-pin.
//
// Two stops is the shape "follow this track" produces: it lays up to nine vias
// on the single leg between one pair, and one drag in the rail deleted all of
// them, with no prompt and no undo.
if (stopCount <= 2) return EMPTY_REANCHORING
const afterRemove = reanchorAfterRemove(vias, from, stopCount)
const dropped = new Set(afterRemove.remove)
const movedTo = new Map(afterRemove.vias.map(v => [v.id, v.after_order_index] as const))
// The list the insert acts on is the one the removal left behind, so each via is
// carried through at whatever index it holds by then.
const shifted: AnchoredVia[] = vias
.filter(v => !dropped.has(v.id))
.map(v => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index }))
const afterInsert = reanchorAfterInsert(shifted, to, () => true)
const finalIndex = new Map(afterInsert.vias.map(v => [v.id, v.after_order_index] as const))
const result: ReanchoredVia[] = []
for (const via of vias) {
if (dropped.has(via.id)) continue
const mid = movedTo.get(via.id) ?? via.after_order_index
const end = finalIndex.get(via.id) ?? mid
if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end })
}
return { vias: result, remove: afterRemove.remove }
}
/** Whether a re-anchoring has anything to write at all. */
export function isEmptyReanchoring(r: Reanchoring): boolean {
return r.vias.length === 0 && r.remove.length === 0
}
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { serviceMarkerHtml, serviceMarkerOuter, SERVICE_MARKER_SIZE, SERVICE_MARKER_SIZE_SELECTED } from './serviceMarker'
import { serviceColor } from './roadtripModel'
/**
* FE-SVCMARK-001..007 — the disc a service stop gets on the map.
*
* Both renderers need the same string: one hands it to a Leaflet divIcon, the
* other to an element it attaches itself. What is worth pinning is that an
* ordinary place gets nothing at all — a petrol station is something the drive
* passes through, and giving it a place-sized marker made a day look like it had
* twice the stops it has — and that the anchor arithmetic includes the border,
* or the pin sits a couple of pixels off the coordinate it marks.
*/
describe('serviceMarkerHtml', () => {
it('FE-SVCMARK-001: an ordinary place gets no service marker', () => {
// Null is the signal to fall back to the place marker, so this is what keeps
// a destination from being drawn as a fuel stop.
expect(serviceMarkerHtml(null, false)).toBeNull()
expect(serviceMarkerHtml(undefined, false)).toBeNull()
expect(serviceMarkerHtml('', false)).toBeNull()
})
it('FE-SVCMARK-002: each kind of stop gets a disc in its own colour', () => {
// All six, not only the four the corridor picker offers: a meal and a
// viewpoint are pauses on the way too, and the map draws them the same way.
for (const kind of ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']) {
const html = serviceMarkerHtml(kind, false)
expect(html, kind).toBeTruthy()
expect(html, kind).toContain(serviceColor(kind))
expect(html, kind).toContain('border-radius:50%')
}
})
it('FE-SVCMARK-003: an unknown service kind is still not drawn as one', () => {
// isServiceStopType is the gate; a value the picker never offers must not
// reach the renderer and produce a disc in an undefined colour.
expect(serviceMarkerHtml('banana', false)).toBeNull()
})
it('FE-SVCMARK-004: selecting one grows it, the way a place marker confirms a click', () => {
const plain = serviceMarkerHtml('fuel', false)!
const selected = serviceMarkerHtml('fuel', true)!
expect(plain).toContain(`width:${SERVICE_MARKER_SIZE}px`)
expect(selected).toContain(`width:${SERVICE_MARKER_SIZE_SELECTED}px`)
expect(SERVICE_MARKER_SIZE_SELECTED).toBeGreaterThan(SERVICE_MARKER_SIZE)
})
it('FE-SVCMARK-005: selected carries the heavier ring, unselected the plain shadow', () => {
expect(serviceMarkerHtml('fuel', true)!).toContain('0 0 0 3px')
expect(serviceMarkerHtml('fuel', false)!).not.toContain('0 0 0 3px')
})
it('FE-SVCMARK-006: the icon is drawn white and inline, never a logo or a photo', () => {
// A fuel stop used to come back from the place search carrying the
// operator's logo, so the map showed a brand roundel where every other
// road-trip surface showed a pump.
const html = serviceMarkerHtml('fuel', false)!
expect(html).toContain('<svg')
expect(html).toContain('#fff')
expect(html).not.toContain('<img')
expect(html).not.toContain('background-image')
})
})
describe('serviceMarkerOuter', () => {
it('FE-SVCMARK-007: the box an anchor centres on includes the border', () => {
// content-box plus a 2px border on each side. Measuring the inner size would
// leave every service pin two pixels off the coordinate it marks.
expect(serviceMarkerOuter(false)).toBe(SERVICE_MARKER_SIZE + 4)
expect(serviceMarkerOuter(true)).toBe(SERVICE_MARKER_SIZE_SELECTED + 4)
})
})
@@ -0,0 +1,55 @@
import { createElement } from 'react'
import { ParkingSquare } from 'lucide-react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { isServiceStopType, serviceColor } from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds'
/**
* Smaller than a place marker on purpose.
*
* A petrol station is not a destination — it is something the drive passes through — and
* a marker the same size as the places the trip is for made a day look like it had twice
* as many stops as it has. Selected grows a little, the same way a place marker does, so
* clicking one still confirms itself.
*/
export const SERVICE_MARKER_SIZE = 24
export const SERVICE_MARKER_SIZE_SELECTED = 30
const BORDER = 2
/** The full box a service marker occupies, border included — what an anchor centres on. */
export function serviceMarkerOuter(selected: boolean): number {
return (selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE) + BORDER * 2
}
/**
* The marker for a stop that interrupts the drive, or null for an ordinary place.
*
* One flat disc in the kind's own colour with a white icon — never the place's photo or
* its brand logo. A fuel stop used to come back from the place search carrying the
* operator's logo, so the map showed an Esso roundel where every other road-trip surface
* showed a pump: unreadable at marker size, different for every brand, and nothing at all
* for the ones with no logo on file.
*
* Built as markup rather than as a component because both renderers need a string: one
* feeds it to a Leaflet `divIcon`, the other to an element it attaches itself.
*/
export function serviceMarkerHtml(stopType: string | null | undefined, selected: boolean): string | null {
if (!isServiceStopType(stopType)) return null
const size = selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE
const Icon = STOP_KIND_BY_KEY[stopType ?? '']?.Icon ?? ParkingSquare
const svg = renderIconMarkup(createElement(Icon, {
size: Math.round(size * 0.56),
color: '#fff',
strokeWidth: 2.4,
}))
const ring = selected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 12px rgba(0,0,0,0.3)'
: '0 2px 6px rgba(0,0,0,0.25)'
return `<div style="
width:${size}px;height:${size}px;border-radius:50%;
background:${serviceColor(stopType)};
border:${BORDER}px solid #fff;box-shadow:${ring};
display:flex;align-items:center;justify-content:center;
box-sizing:content-box;cursor:pointer;
">${svg}</div>`
}
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest'
import { roadtripStopTypeSchema } from '@trek/shared'
import { SERVICE_STOP_TYPES, SERVICE_COLORS } from './roadtripModel'
import {
STOP_KINDS, STOP_KIND_BY_KEY, CORRIDOR_CATEGORIES, CORRIDOR_CATEGORY_BY_KEY, CORRIDOR_CATEGORY_KEYS,
SERVICE_KIND_KEYS, REFUELLING_STOP_TYPES, isOvernightCategory,
} from './stopKinds'
/**
* The table exists to stop nine lists from drifting apart, and these are the assertions
* that make that true rather than intended. Each one pins the table against a list that
* cannot import it: the Zod enum lives in `@trek/shared` and must not depend on the
* client, and `SERVICE_STOP_TYPES` lives in the React-free model.
*/
describe('stop kinds', () => {
it('FE-STOPKIND-001: covers exactly the kinds the shared enum allows', () => {
const fromSchema = [...roadtripStopTypeSchema.options].sort()
const fromTable = STOP_KINDS.map(k => k.key).sort()
expect(fromTable).toEqual(fromSchema)
})
it('FE-STOPKIND-002: agrees with the model about which kinds interrupt a drive', () => {
expect([...SERVICE_KIND_KEYS].sort()).toEqual([...SERVICE_STOP_TYPES].sort())
})
it('FE-STOPKIND-003: every kind carries the colour the model gives it', () => {
for (const kind of STOP_KINDS) expect(kind.color).toBe(SERVICE_COLORS[kind.key])
})
it('FE-STOPKIND-004: a refuelling kind is a kind, so a range budget cannot reset on a typo', () => {
for (const key of REFUELLING_STOP_TYPES) expect(STOP_KIND_BY_KEY[key]).toBeDefined()
})
it('FE-STOPKIND-005: the corridor offers every category in a stable order', () => {
// Sleeping sits next to sleeping: hotel follows campsite rather than landing wherever
// the stop-kind table happens to end.
expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'hotel', 'restaurant', 'sights'])
})
it('FE-STOPKIND-008: a hotel is a category without being a stop kind', () => {
// The whole reason the two lists are separate. Sleeping somewhere ends the day rather
// than interrupting the drive, so a hotel keeps its number in the chain and never
// becomes a stop_type.
expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBeNull()
expect(STOP_KIND_BY_KEY.hotel).toBeUndefined()
expect(SERVICE_KIND_KEYS).not.toContain('hotel')
})
it('FE-STOPKIND-009: a night can be booked at a campsite too, so the popup asks', () => {
expect(isOvernightCategory('hotel')).toBe(true)
expect(isOvernightCategory('campsite')).toBe(true)
expect(isOvernightCategory('fuel')).toBe(false)
expect(isOvernightCategory(null)).toBe(false)
})
it('FE-STOPKIND-010: every category that is a stop kind names the kind it creates', () => {
for (const category of CORRIDOR_CATEGORIES) {
if (category.stopKind === null) continue
expect(category.stopKind).toBe(category.key)
expect(STOP_KIND_BY_KEY[category.key]).toBeDefined()
}
})
it('FE-STOPKIND-006: two kinds are named under a key of their own, and that is on purpose', () => {
// Spelled out rather than derived: the corridor calls a rest area rest_area and names
// it under rest, a restaurant restaurant and names it under food. A derived label key
// would silently ask for two translations that do not exist.
expect(STOP_KIND_BY_KEY.rest_area.labelKey).toBe('roadtrip.poi.rest')
expect(STOP_KIND_BY_KEY.restaurant.labelKey).toBe('roadtrip.poi.food')
for (const kind of STOP_KINDS) {
if (kind.key !== 'rest_area' && kind.key !== 'restaurant') {
expect(kind.labelKey).toBe(`roadtrip.poi.${kind.key}`)
}
}
})
it('FE-STOPKIND-007: every kind offers a dwell the popup can pre-fill', () => {
for (const kind of STOP_KINDS) {
expect(kind.defaultMinutes).toBeGreaterThan(0)
expect(Number.isInteger(kind.defaultMinutes)).toBe(true)
}
})
})
+120
View File
@@ -0,0 +1,120 @@
import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, BedDouble, type LucideIcon } from 'lucide-react'
import { SERVICE_COLORS, SERVICE_STOP_TYPES } from './roadtripModel'
import type { RoadtripStopType } from '@trek/shared'
/**
* One row per kind of stop a drive can have, and the only place a new one is added.
*
* The six kinds used to be written out in nine separate lists: the enum in `@trek/shared`,
* `SERVICE_STOP_TYPES` and `SERVICE_COLORS` here in the model, the corridor's category
* keys, the map's `ROADTRIP_POI_CATEGORIES`, the popup's own `STOP_KINDS`, two lookups in
* the rail, one in the map markers and one in the corridor panel. Every one of those
* carried a comment telling the next person to keep the others in step, and it still came
* apart: a rest area was a parking sign in the popup, on the map pill and in the panel,
* and a coffee cup on the rail disc and the map marker, for the same stop. That is fixed
* here in favour of the parking sign, because the colour is already documented as "the
* blue every parking sign in Europe is printed in".
*
* The table lives beside the model rather than in it because an icon is a React component
* and `roadtripModel.ts` is deliberately free of React so it can be unit tested on its
* own. The colours and the service list stay there and are read from here.
*
* `isService` and `isCorridorCategory` are not the same question, and a coming lodging
* kind is why they are separate: the corridor can find a hotel, but sleeping somewhere
* ends the day rather than interrupting the drive, so it would be a category without
* being a service stop.
*/
export interface StopKind {
key: RoadtripStopType
/** Two of the six do not match their key: `rest_area` is named under `rest`, `restaurant` under `food`. */
labelKey: string
Icon: LucideIcon
color: string
/** What the popup offers before anyone changes it — how long this kind usually takes. */
defaultMinutes: number
/** Interrupts the drive: drawn on the dashed line, no number, left out of every stop count. */
isService: boolean
/** Offered as a category in the corridor search. */
isCorridorCategory: boolean
}
export const STOP_KINDS: StopKind[] = [
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, defaultMinutes: 10, isService: true, isCorridorCategory: true },
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, defaultMinutes: 30, isService: true, isCorridorCategory: true },
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, defaultMinutes: 20, isService: true, isCorridorCategory: true },
{ key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: SERVICE_COLORS.campsite, defaultMinutes: 60, isService: true, isCorridorCategory: true },
{ key: 'restaurant', labelKey: 'roadtrip.poi.food', Icon: Utensils, color: SERVICE_COLORS.restaurant, defaultMinutes: 45, isService: true, isCorridorCategory: true },
{ key: 'sights', labelKey: 'roadtrip.poi.sights', Icon: Camera, color: SERVICE_COLORS.sights, defaultMinutes: 30, isService: true, isCorridorCategory: true },
]
/** By key, for the lookups the rail, the markers and the panel used to keep themselves. */
export const STOP_KIND_BY_KEY: Record<string, StopKind> = Object.fromEntries(
STOP_KINDS.map(k => [k.key, k]),
)
/**
* A category the corridor search offers, which is not the same question as a stop kind.
*
* Every stop kind is a category, but not every category is a stop kind: somewhere to
* sleep is something the search can find, and staying there ends the day rather than
* interrupting the drive. A hotel therefore keeps its number in the chain and gets a row
* in `day_accommodations`; it never becomes a `stop_type`.
*/
export interface CorridorCategory {
key: string
labelKey: string
Icon: LucideIcon
color: string
/** The kind a hit of this category becomes, or null when it becomes something else. */
stopKind: RoadtripStopType | null
}
/** Somewhere to sleep. Blue rather than the campsite's green: both are a night, but one
* of them has a roof, and telling the two apart on a map matters more than grouping them.
* theme-lint-disable — the road-signage palette, shared with the general place search so
* a hotel found along a route looks like the hotels found anywhere else. */
export const HOTEL_COLOR = '#2563EB'
/**
* The categories, in the order the panel shows them.
*
* Written out rather than derived, because the order is a judgement: sleeping sits next
* to sleeping, so `hotel` follows `campsite` instead of landing wherever the stop-kind
* table happens to end.
*/
export const CORRIDOR_CATEGORIES: CorridorCategory[] = [
...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights')
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: null },
...STOP_KINDS.filter(k => k.key === 'restaurant' || k.key === 'sights')
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
]
export const CORRIDOR_CATEGORY_BY_KEY: Record<string, CorridorCategory> = Object.fromEntries(
CORRIDOR_CATEGORIES.map(c => [c.key, c]),
)
/** The keys the corridor search offers, in the order the panel shows them. */
export const CORRIDOR_CATEGORY_KEYS = CORRIDOR_CATEGORIES.map(c => c.key)
/** Categories whose hit becomes a night rather than a pause. */
export function isOvernightCategory(category: string | null | undefined): boolean {
// Campsite is both: it is a service stop today and stays one, but a night can be
// booked there just as well, so the popup offers the choice rather than deciding.
return category === 'hotel' || category === 'campsite'
}
/**
* A guard against the drift this file exists to end.
*
* `SERVICE_STOP_TYPES` is the model's own list and stays there, because the arithmetic
* must not depend on anything that imports React. The two are checked against each other
* in `stopKinds.test.ts` rather than derived, so a kind added to one and forgotten in the
* other fails a test instead of quietly counting as a destination.
*/
export const SERVICE_KIND_KEYS = STOP_KINDS.filter(k => k.isService).map(k => k.key)
/** The kinds where a tank or a battery is filled, so a range budget starts over. */
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
export { SERVICE_STOP_TYPES }
@@ -0,0 +1,39 @@
/**
* The road trip's type scale, pinned to the day plan's.
*
* Both road trip panels sit beside the day plan and the tab switcher flips between
* them, so a road trip whose names ran a size larger than the day plan's read as a
* different app.
* The two anchors are the day plan's own: a day title is 14px and a place name 12.5px
* (`DayPlanSidebar`), and everything here is derived from those. The design handoff was
* drawn a fifth larger throughout — its proportions are kept, its absolute sizes are not.
*
* Written as `calc(px * var(--fs-scale-<tier>))` rather than as a tier utility because
* the tiers land on 24/18/14/12px and this rail needs the steps between them; the
* multiplier is what keeps the user's per-tier text-size setting reaching them. A value
* and its unit always share one tier, so scaling one tier cannot break a pair apart.
*/
export const FS = {
/** A total in the head. */
total: 'calc(20px * var(--fs-scale-title, 1))',
/** The unit hanging off one. */
totalUnit: 'calc(11.5px * var(--fs-scale-title, 1))',
/** A day's name — the day plan's own size. */
dayTitle: 'calc(14px * var(--fs-scale-body, 1))',
/** A stop's name — the day plan's own size. */
name: 'calc(12.5px * var(--fs-scale-body, 1))',
/** A clock reading, and a service stop's dwell. */
time: 'calc(11px * var(--fs-scale-caption, 1))',
/** A drive band, a date, a line of explanation. */
meta: 'calc(10px * var(--fs-scale-caption, 1))',
/** The number in a marker. */
marker: 'calc(10.5px * var(--fs-scale-caption, 1))',
/** A badge, a caption over a total. */
label: 'calc(9px * var(--fs-scale-caption, 1))',
/** The word "Stay" — the smallest thing in the rail. */
micro: 'calc(8px * var(--fs-scale-caption, 1))',
/** The name of a panel — "Along the route". The largest text either panel carries. */
panelTitle: 'calc(15px * var(--fs-scale-subtitle, 1))',
/** A pill, a segment, a line of progress: anything you press but do not read as prose. */
control: 'calc(10.5px * var(--fs-scale-body, 1))',
} as const
@@ -0,0 +1,187 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { LatLng } from './corridor'
const { pois } = vi.hoisted(() => ({ pois: vi.fn() }))
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
import { useCorridorPois } from './useCorridorPois'
/** A short drive west out of Hamburg — enough route to tile, few enough boxes to be quick. */
const LINE: LatLng[] = [
{ lat: 53.55, lng: 9.99 },
{ lat: 53.5, lng: 9.8 },
{ lat: 53.45, lng: 9.6 },
]
const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
osm_id: id,
name: id,
lat,
lng,
category,
poi_type: 'amenity=fuel',
address: null,
website: null,
phone: null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap',
})
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, source: 'openstreetmap', truncated: false, clamped: false })
beforeEach(() => {
pois.mockReset()
pois.mockResolvedValue(answer())
})
describe('useCorridorPois', () => {
it('FE-ROADTRIP-CORRIDOR-001: asks each box once for every category together', async () => {
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel', 'charging', 'rest_area'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(pois).toHaveBeenCalled()
// The whole point of the batching: the kinds ride along in one query per box.
for (const call of pois.mock.calls) expect(call[0]).toBe('fuel,charging,rest_area')
expect(result.current.progress.total).toBe(pois.mock.calls.length)
})
it('FE-ROADTRIP-CORRIDOR-002: keeps what is near the route and drops what is not', async () => {
// One beside the line, one far north of it.
pois.mockResolvedValue(answer(hit('near', 53.5, 9.81), hit('far', 54.6, 9.8)))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 5))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.results.map(r => r.osm_id)).toEqual(['near'])
expect(result.current.results[0].offRouteKm).toBeLessThan(5)
expect(result.current.results[0].alongKm).toBeGreaterThan(0)
})
it('FE-ROADTRIP-CORRIDOR-003: orders hits by how far along the drive they are', async () => {
pois.mockResolvedValue(answer(
hit('late', 53.45, 9.61),
hit('early', 53.55, 9.98),
hit('middle', 53.5, 9.8),
))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 8))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.results.map(r => r.osm_id)).toEqual(['early', 'middle', 'late'])
})
it('FE-ROADTRIP-CORRIDOR-004: the same place found in two overlapping boxes is listed once', async () => {
pois.mockResolvedValue(answer(hit('shared', 53.5, 9.81)))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.results.filter(r => r.osm_id === 'shared')).toHaveLength(1)
})
it('FE-ROADTRIP-CORRIDOR-005: a box that times out is asked again before it counts as unsearched', async () => {
let first = true
pois.mockImplementation(async () => {
if (first) { first = false; throw new Error('502') }
return answer(hit('found', 53.5, 9.81))
})
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.failedAreas).toBe(0)
expect(result.current.error).toBe(false)
expect(result.current.results.length).toBeGreaterThan(0)
})
it('FE-ROADTRIP-CORRIDOR-006: a box that fails twice is reported rather than silently skipped', async () => {
pois.mockRejectedValue(new Error('502'))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.failedAreas).toBeGreaterThan(0)
// Nothing answered at all, so this is an outage and not a partial result.
expect(result.current.error).toBe(true)
})
it('FE-ROADTRIP-CORRIDOR-007: a drive longer than the request budget says the tail went unsearched', async () => {
const long: LatLng[] = Array.from({ length: 40 }, (_, i) => ({ lat: 53.5 - i * 0.25, lng: 9.9 + i * 0.35 }))
const { result } = renderHook(() => useCorridorPois(long, ['fuel'], 5))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.capped).toBe(true)
expect(result.current.progress.total).toBeLessThanOrEqual(16)
})
it('FE-ROADTRIP-CORRIDOR-008: changing what is wanted drops the answers to the old question', async () => {
pois.mockResolvedValue(answer(hit('a', 53.5, 9.81)))
const { result, rerender } = renderHook(
({ categories }: { categories: string[] }) => useCorridorPois(LINE, categories, 10),
{ initialProps: { categories: ['fuel'] } },
)
act(() => { result.current.search() })
await waitFor(() => expect(result.current.results.length).toBeGreaterThan(0))
rerender({ categories: ['campsite'] })
await waitFor(() => expect(result.current.results).toEqual([]))
expect(result.current.progress).toEqual({ done: 0, total: 0 })
})
it('FE-ROADTRIP-CORRIDOR-009: a route too short to search asks for nothing', async () => {
const { result } = renderHook(() => useCorridorPois([LINE[0]], ['fuel'], 10))
act(() => { result.current.search() })
await act(async () => {})
expect(pois).not.toHaveBeenCalled()
expect(result.current.loading).toBe(false)
})
it('FE-ROADTRIP-CORRIDOR-010: with nothing selected there is nothing to look for', async () => {
const { result } = renderHook(() => useCorridorPois(LINE, [], 10))
act(() => { result.current.search() })
await act(async () => {})
expect(pois).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-CORRIDOR-011: clearing stops the run and empties the list', async () => {
pois.mockResolvedValue(answer(hit('a', 53.5, 9.81)))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.results.length).toBeGreaterThan(0))
act(() => { result.current.clear() })
expect(result.current.results).toEqual([])
expect(result.current.loading).toBe(false)
expect(result.current.failedAreas).toBe(0)
})
it('FE-ROADTRIP-CORRIDOR-012: leaving the view aborts the boxes still in flight', async () => {
let seen: AbortSignal | undefined
pois.mockImplementation((_c: string, _b: unknown, _l: string, signal: AbortSignal) => {
seen = signal
return new Promise(() => {})
})
const { result, unmount } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(seen).toBeDefined())
unmount()
expect(seen!.aborted).toBe(true)
})
})
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
import type { Poi } from '../Map/poiCategories'
/** A POI that is actually on the way, with the two numbers that make it one. */
export interface CorridorPoi extends Poi {
offRouteKm: number
alongKm: number
}
export interface CorridorSearch {
results: CorridorPoi[]
/** Areas answered so far, out of how many the route was cut into. */
progress: { done: number; total: number }
loading: boolean
/** True when the route was longer than the request budget and the tail was left out. */
capped: boolean
/** Boxes the place search could not answer — a partial result is still shown. */
failedAreas: number
/**
* Boxes where the server hit its own per-request ceiling and dropped the rest.
* Distinct from `capped`, which is about stretches never asked for at all: here the
* stretch was searched and the answer came back short. Without saying so, filtering
* the results cannot tell "nothing of that name on the way" from "cut off before it".
*/
truncatedAreas: number
error: boolean
/** The thinned line every `alongKm` above is measured along. */
spine: LatLng[]
search: () => void
clear: () => void
}
/**
* How many bounding boxes one search may ask Overpass for. A German-to-Italian drive
* would otherwise be dozens of requests against a shared public mirror; past this the
* search stops and says so rather than quietly covering half the route.
*/
const MAX_TILES = 16
/** Gap after a request before the same worker starts the next — the mirrors are shared. */
const REQUEST_SPACING_MS = 250
/** How many boxes are in flight at once. */
const CONCURRENT_REQUESTS = 5
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise(resolve => {
if (signal.aborted) { resolve(); return }
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal.removeEventListener('abort', done)
resolve()
}
signal.addEventListener('abort', done, { once: true })
})
/**
* "What is on the way" for a drawn route.
*
* Deliberately manual: the search runs when the user asks for it, never on pan or on
* every route change, because each run is several Overpass requests and the mirrors are
* shared. Results stay until the categories or the route change.
*/
export function useCorridorPois(line: LatLng[], categories: string[], widthKm: number): CorridorSearch {
const { locale } = useTranslation()
const [results, setResults] = useState<CorridorPoi[]>([])
const [progress, setProgress] = useState({ done: 0, total: 0 })
const [loading, setLoading] = useState(false)
const [capped, setCapped] = useState(false)
const [failedAreas, setFailedAreas] = useState(0)
const [truncatedAreas, setTruncatedAreas] = useState(0)
const [error, setError] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const runIdRef = useRef(0)
/**
* The routed line, thinned. A corridor built from every vertex would make hundreds of
* overlapping boxes out of what a handful describe just as well.
*
* Shared rather than local to the search, because every `alongKm` in the results is a
* distance along THIS line. Anything comparing a hit against the day's own stops has to
* project them onto the same one, or the two sets of numbers do not mean the same thing.
*/
const spine = useMemo(() => simplifyLine(line, Math.max(1, widthKm / 3)), [line, widthKm])
const clear = useCallback(() => {
abortRef.current?.abort()
setResults([])
setProgress({ done: 0, total: 0 })
setLoading(false)
setCapped(false)
setFailedAreas(0)
setTruncatedAreas(0)
setError(false)
}, [])
// A changed route or category set makes the old answers wrong, not stale — drop them.
const routeKey = line.length ? `${line.length}:${line[0].lat.toFixed(3)},${line[0].lng.toFixed(3)}` : ''
const catKey = categories.join(',')
useEffect(() => { clear() }, [routeKey, catKey, widthKm, clear])
const search = useCallback(() => {
abortRef.current?.abort()
if (spine.length < 2 || categories.length === 0) return
const controller = new AbortController()
abortRef.current = controller
const runId = ++runIdRef.current
const allTiles = corridorTiles(spine, widthKm)
const tiles = allTiles.slice(0, MAX_TILES)
setCapped(allTiles.length > tiles.length)
setFailedAreas(0)
setTruncatedAreas(0)
setError(false)
setResults([])
setLoading(true)
setProgress({ done: 0, total: tiles.length })
// All the wanted kinds in one query per box. Asking per kind was the same answer for
// four times the requests, and every extra request is another chance for a shared
// mirror to time out — which is what made a search both slow and patchy.
const wanted = categories.join(',')
const jobs = [...tiles]
void (async () => {
const seen = new Map<string, CorridorPoi>()
let failures = 0
let shortened = 0
let done = 0
let next = 0
// Several at a time. Following the real road rather than the line between stops
// means more boxes to cover the same drive, and asking for them one after another
// made a search take the better part of a minute. The server races several
// Overpass mirrors per request and caches what comes back, so the boxes overlap
// cheaply; the small gap afterwards keeps one worker from queueing straight up.
const collect = async (tile: Bbox): Promise<boolean> => {
try {
const data = await mapsApi.pois(wanted, tile, locale, controller.signal)
// The server answers with at most a few hundred per box and says so. A hit
// dropped there is invisible here, so the count has to reach the surface.
if (data.truncated) setTruncatedAreas(++shortened)
for (const poi of data.pois) {
if (seen.has(poi.osm_id)) continue
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
if (!hit || hit.offRouteKm > widthKm) continue
seen.set(poi.osm_id, { ...poi, offRouteKm: hit.offRouteKm, alongKm: hit.alongKm })
}
return true
} catch {
return false
}
}
const publish = (): void => {
setProgress({ done, total: jobs.length })
setResults([...seen.values()].sort((a, b) => a.alongKm - b.alongKm))
}
const retryable: Bbox[] = []
const worker = async (): Promise<void> => {
while (next < jobs.length) {
if (controller.signal.aborted || runId !== runIdRef.current) return
const job = jobs[next++]
if (!(await collect(job))) retryable.push(job)
done++
if (controller.signal.aborted || runId !== runIdRef.current) return
publish()
await sleep(REQUEST_SPACING_MS, controller.signal)
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENT_REQUESTS, jobs.length) }, worker))
if (controller.signal.aborted || runId !== runIdRef.current) return
// One more pass over what did not answer. Overpass mirrors time out under load far
// more often than they are truly empty, and reporting "4 stretches went unsearched"
// when a second ask would have answered them is the wrong end of the trade.
for (const job of retryable) {
if (controller.signal.aborted || runId !== runIdRef.current) return
if (await collect(job)) continue
failures++
setFailedAreas(failures)
}
publish()
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
}, [spine, categories, widthKm, locale])
useEffect(() => () => abortRef.current?.abort(), [])
return { results, progress, loading, capped, failedAreas, truncatedAreas, error, spine, search, clear }
}
@@ -0,0 +1,237 @@
import { renderHook, act, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
import type { RoadtripVias } from './useRoadtripVias'
import type { TrackPlace } from './useFollowTrack'
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
import { useFollowTrack } from './useFollowTrack'
/**
* The hook is the part that talks to two services and has to survive both of them going
* wrong halfway. The refinement itself is `followTrack`'s business and tested there; what
* matters here is what reaches the day and what reaches the traveller.
*/
const START = { lat: 52, lng: 13 }
const END = { lat: 52, lng: 14 }
/** A track arching north away from the straight line between the day's two stops. */
function arc(points = 60, bulgeDeg = 0.1): [number, number][] {
return Array.from({ length: points + 1 }, (_, i) => [
52 + bulgeDeg * Math.sin((Math.PI * i) / points),
13 + i / points,
])
}
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 7, dayNumber: 1, date: null, title: null,
stops: [
{ assignmentId: 1, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
],
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
geometry: [[START.lat, START.lng], [END.lat, END.lng]],
distance: 0, duration: 0,
...over,
}
}
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
})
function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {
return {
byDay: {},
trackByDay: {},
stale: false,
editable: true,
add: vi.fn(),
addMany: vi.fn().mockResolvedValue(undefined),
move: vi.fn(),
remove: vi.fn(),
reanchor: vi.fn(),
...over,
}
}
const TRACK: TrackPlace = { id: 3, name: 'Atlantic Road', route_geometry: JSON.stringify(arc()), route_color: '#0ea5e9' }
const PLAIN: TrackPlace = { id: 4, name: 'A hotel', route_geometry: null }
const FAR: TrackPlace = { id: 5, name: 'Somewhere else', route_geometry: JSON.stringify(arc().map(([la, ln]) => [la + 4, ln])) }
/**
* A router with no idea about roads: it drives straight from waypoint to waypoint.
*
* Which is exactly what makes the refinement work for its living — each via added bends
* the answer a little closer to the arc, so the loop converges instead of running to its
* cap the way a fixed answer would.
*/
function answersStraight(): void {
calculateRouteWithLegs.mockImplementation(async (waypoints: { lat: number; lng: number }[]) => ({
coordinates: waypoints.map(w => [w.lat, w.lng]),
distance: 0, duration: 0, legs: [],
}))
}
beforeEach(() => {
calculateRouteWithLegs.mockReset()
})
describe('useFollowTrack', () => {
it('FE-FOLLOWHOOK-001: nothing is parsed while the dialog is closed', () => {
const { result } = renderHook(() => useFollowTrack(1, [TRACK, PLAIN], routes([day()]), viasStub()))
expect(result.current.dayId).toBeNull()
// Tens of thousands of points per track, for a dialog nobody opened.
expect(result.current.tracks).toEqual([])
// But it still knows there is something to open, without parsing a single point.
expect(result.current.available).toBe(true)
})
it('FE-FOLLOWHOOK-011: the track a day follows is read back from the day, not remembered', () => {
// The whole point of storing it: a field that only ever held what this session did
// would look right until somebody refreshed the page.
const vias = viasStub({ trackByDay: { 7: { day_id: 7, place_id: 3, stray_km: 0.8 } } });
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
expect(result.current.namesByDay).toEqual({ 7: 'Atlantic Road' })
act(() => { result.current.open(7) })
expect(result.current.current).toEqual({ name: 'Atlantic Road', strayKm: 0.8 })
})
it('FE-FOLLOWHOOK-012: a stored track whose place is gone names nothing', () => {
// The row cascades with the place, so this is belt and braces — but a label that
// outlived its line would be a day claiming to follow something invisible.
const vias = viasStub({ trackByDay: { 7: { day_id: 7, place_id: 999, stray_km: null } } })
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
expect(result.current.current).toBeNull()
})
it('FE-FOLLOWHOOK-010: a trip that imported nothing offers no way in', () => {
const { result } = renderHook(() => useFollowTrack(1, [PLAIN], routes([day()]), viasStub()))
expect(result.current.available).toBe(false)
})
it('FE-FOLLOWHOOK-002: opening lists only the places that carry a line, nearest first', () => {
const { result } = renderHook(() => useFollowTrack(1, [FAR, PLAIN, TRACK], routes([day()]), viasStub()))
act(() => { result.current.open(7) })
expect(result.current.tracks.map(t => t.id)).toEqual([3, 5])
expect(result.current.tracks[0].name).toBe('Atlantic Road')
expect(result.current.tracks[0].color).toBe('#0ea5e9')
expect(result.current.tracks[0].lengthKm).toBeGreaterThan(60)
expect(result.current.tracks[0].gapKm).toBeLessThan(result.current.tracks[1].gapKm)
})
it('FE-FOLLOWHOOK-003: applying writes the plan onto the day, legs and all', async () => {
answersStraight()
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
expect(vias.addMany).toHaveBeenCalledTimes(1)
const [dayId, written, legs, track] = (vias.addMany as ReturnType<typeof vi.fn>).mock.calls[0]
expect(dayId).toBe(7)
expect(legs).toEqual([0])
// The chain and the name it came from land in one write: a day claiming to follow a
// road whose vias never arrived would be worse than a day claiming nothing.
expect(track).toMatchObject({ place_id: 3 })
expect(written.length).toBeGreaterThan(0)
// Every via hangs behind the first stop, because the day has exactly one leg.
expect(written.every((v: { after_order_index: number }) => v.after_order_index === 0)).toBe(true)
expect(result.current.outcome?.vias).toBe(written.length)
expect(result.current.busy).toBe(false)
})
it('FE-FOLLOWHOOK-004: a track the drive already follows writes nothing but says so', async () => {
// The router answers with the track itself, so there is nothing left to pull onto.
calculateRouteWithLegs.mockResolvedValue({ coordinates: arc(), distance: 0, duration: 0, legs: [] })
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
// Still a write: the legs are cleared so a second track does not stack, and the name
// is recorded even though the drive needed no bending to follow it.
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], { place_id: 3, stray_km: 0 })
})
it('FE-FOLLOWHOOK-005: a routing outage is reported rather than written as an empty plan', async () => {
calculateRouteWithLegs.mockRejectedValue(new Error('429'))
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
expect(result.current.error).toBe('route')
expect(result.current.busy).toBe(false)
expect(vias.addMany).not.toHaveBeenCalled()
// One retry, because a refinement is several calls against a rate-limited host.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
}, 10000)
it('FE-FOLLOWHOOK-006: a save that fails says so instead of claiming the day changed', async () => {
answersStraight()
const vias = viasStub({ addMany: vi.fn().mockRejectedValue(new Error('boom')) })
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
expect(result.current.error).toBe('save')
expect(result.current.outcome).toBeNull()
expect(result.current.busy).toBe(false)
}, 10000)
it('FE-FOLLOWHOOK-007: clearing drops the vias of every leg of the day', async () => {
const vias = viasStub({ byDay: { 7: [{ id: 1 }, { id: 2 }] as never } })
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
expect(result.current.viaCount).toBe(2)
await act(async () => { await result.current.clear() })
// null, not nothing: dropping the points is also dropping the claim.
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], null)
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
})
it('FE-FOLLOWHOOK-008: closing abandons a run rather than letting it land afterwards', async () => {
let release: ((value: unknown) => void) | null = null
calculateRouteWithLegs.mockImplementation(() => new Promise(resolve => { release = resolve }))
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
act(() => { result.current.open(7) })
let running: Promise<void> | null = null
act(() => { running = result.current.apply(3) })
await waitFor(() => { expect(result.current.busy).toBe(true) })
act(() => { result.current.close() })
await act(async () => {
release?.({ coordinates: [[START.lat, START.lng], [END.lat, END.lng]], distance: 0, duration: 0, legs: [] })
await running
})
// The day is left exactly as it was: an abandoned plan is not half a chain of vias.
expect(vias.addMany).not.toHaveBeenCalled()
expect(result.current.dayId).toBeNull()
expect(result.current.error).toBeNull()
})
it('FE-FOLLOWHOOK-009: a day with nothing to drive between is not applied to', async () => {
answersStraight()
const vias = viasStub()
const lonely = day({ stops: [day().stops[0]] })
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([lonely]), vias))
act(() => { result.current.open(7) })
await act(async () => { await result.current.apply(3) })
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
expect(vias.addMany).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,255 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs } from '../Map/RouteCalculator'
import { lineLengthKm, parseTrack, planTrack, trackGapKm, type FollowTrackPlan } from './followTrack'
import type { LatLng } from './corridor'
import type { RoadtripRoutes } from './useRoadtripRoutes'
import type { RoadtripVias } from './useRoadtripVias'
/**
* The dialog state behind "this day follows that track".
*
* Kept out of the dialog because applying one is a long job — a round trip to the routing
* host per refinement — and a component that unmounts halfway would leave the day with
* half a chain of vias on it. Here it can be aborted, and here it can say how far it has
* got while it runs.
*/
/** A track of this trip, ready to be offered. */
export interface TrackChoice {
id: number
name: string
/** The colour the map already draws it in, so the list and the line agree. */
color: string | null
points: LatLng[]
lengthKm: number
/** How far the track runs from the open day at its closest — what orders the list. */
gapKm: number
}
/** What became of an applied track. */
export interface FollowTrackOutcome {
vias: number
strayKm: number
capped: boolean
}
export interface FollowTrack {
/** The day the dialog is open for, or null when it is closed. */
dayId: number | null
open: (dayId: number) => void
close: () => void
/** The trip's tracks, nearest to this day first. Empty until the dialog opens. */
tracks: TrackChoice[]
busy: boolean
/** Which refinement round is running, for a dialog that has to say something. */
round: number
error: 'route' | 'save' | null
outcome: FollowTrackOutcome | null
apply: (trackId: number) => Promise<void>
/** Drops every via of the open day, so it drives the way the router would. */
clear: () => Promise<void>
/** How many vias the open day carries — what makes clearing worth offering. */
viaCount: number
/**
* The track the open day follows, if it follows one.
*
* Read back from the day rather than remembered from the run that applied it: the point
* of storing it at all is that it survives a reload, and a field that only ever held
* what this session did would look right until somebody refreshed.
*/
current: { name: string; strayKm: number | null } | null
/** The name of the track each day follows, for the rail's badge. */
namesByDay: Record<number, string>
/**
* Whether the trip holds a track at all.
*
* Read off the column rather than off `tracks`, which is empty until the dialog opens
* and would therefore never let it be opened. It is what keeps the rail from offering
* a control whose only possible answer is "no tracks in this trip".
*/
available: boolean
}
/** A place is a track when it carries a line. */
export interface TrackPlace {
id: number
name?: string | null
route_geometry?: string | null
route_color?: string | null
}
/** Waiting a beat between rounds: the public routing hosts allow about one call a second. */
export const ROUND_PAUSE_MS = 400
/** How long to wait before the one retry a round gets. */
export const RETRY_PAUSE_MS = 1500
const sleep = (ms: number): Promise<void> => new Promise(resolve => { setTimeout(resolve, ms) })
export function useFollowTrack(
tripId: number | string | null,
places: TrackPlace[],
routes: RoadtripRoutes,
vias: RoadtripVias,
): FollowTrack {
const [dayId, setDayId] = useState<number | null>(null)
const [busy, setBusy] = useState(false)
const [round, setRound] = useState(0)
const [error, setError] = useState<'route' | 'save' | null>(null)
const [outcome, setOutcome] = useState<FollowTrackOutcome | null>(null)
const abort = useRef<AbortController | null>(null)
const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId])
const stops = useMemo<LatLng[]>(() => (day?.stops ?? []).map(s => ({ lat: s.lat, lng: s.lng })), [day])
/**
* Parsed only while the dialog is open.
*
* A track runs to tens of thousands of points and a trip can hold a dozen of them, so
* parsing them to render a rail nobody opened would cost more than the rail does. The
* memo hangs off `dayId` as well as `places` for exactly that reason.
*/
const tracks = useMemo<TrackChoice[]>(() => {
if (dayId === null) return []
const found: TrackChoice[] = []
for (const place of places) {
const points = parseTrack(place.route_geometry)
if (points.length < 2) continue
found.push({
id: place.id,
name: place.name || '',
color: place.route_color ?? null,
points,
lengthKm: lineLengthKm(points),
gapKm: trackGapKm(points, stops),
})
}
// Nearest first: a trip carrying every track of a three-week tour should still open
// on the one that runs along today's drive.
return found.sort((a, b) => a.gapKm - b.gapKm)
}, [dayId, places, stops])
const open = useCallback((next: number) => {
setDayId(next)
setOutcome(null)
setError(null)
setRound(0)
}, [])
const close = useCallback(() => {
abort.current?.abort()
abort.current = null
setDayId(null)
setBusy(false)
setRound(0)
}, [])
const apply = useCallback(async (trackId: number) => {
const track = tracks.find(t => t.id === trackId)
if (!track || dayId === null || stops.length < 2 || busy) return
const controller = new AbortController()
abort.current = controller
setBusy(true)
setError(null)
setOutcome(null)
setRound(0)
let plan: FollowTrackPlan
let spent = 0
try {
plan = await planTrack({
track: track.points,
stops,
signal: controller.signal,
onRound: n => { setRound(n) },
route: async waypoints => {
// Paced before the call rather than after it, so a plan that fits on its first
// round costs nothing in waiting — and given one second chance, because a
// refinement is several calls in a row against a host that allows about one a
// second, and losing the whole loop to a single 429 would restart it at nothing.
if (spent > 0) await sleep(ROUND_PAUSE_MS)
spent += 1
for (let attempt = 0; ; attempt++) {
try {
const r = await calculateRouteWithLegs(waypoints, {
signal: controller.signal,
profile: 'driving',
tripId,
dayId,
})
return r.coordinates.map(([lat, lng]) => ({ lat, lng }))
} catch (err) {
if (attempt >= 1 || controller.signal.aborted) throw err
await sleep(RETRY_PAUSE_MS)
}
}
},
})
} catch {
if (!controller.signal.aborted) setError('route')
setBusy(false)
return
}
if (controller.signal.aborted) { setBusy(false); return }
try {
// The chain and the name it came from, in one write: a day claiming to follow a
// road whose vias never landed would be worse than a day claiming nothing.
await vias.addMany(dayId, plan.vias, plan.legs, { place_id: trackId, stray_km: plan.strayKm })
setOutcome({ vias: plan.vias.length, strayKm: plan.strayKm, capped: plan.capped })
} catch {
setError('save')
} finally {
setBusy(false)
}
}, [tracks, dayId, stops, busy, tripId, vias])
const viaCount = dayId === null ? 0 : (vias.byDay[dayId]?.length ?? 0)
/**
* The name behind each day's stored track id.
*
* A lookup over `places` rather than a parse: naming a line costs a row, and the rail
* asks for this on every render.
*/
const namesByDay = useMemo(() => {
const names: Record<number, string> = {}
const byId = new Map(places.map(p => [p.id, p]))
for (const [day, track] of Object.entries(vias.trackByDay)) {
const place = byId.get(track.place_id)
if (place?.name) names[Number(day)] = place.name
}
return names
}, [places, vias.trackByDay])
const current = useMemo(() => {
if (dayId === null) return null
const track = vias.trackByDay[dayId]
const name = namesByDay[dayId]
return track && name ? { name, strayKm: track.stray_km } : null
}, [dayId, vias.trackByDay, namesByDay])
// A cheap look at the column, not a parse: this runs on every planner render, and the
// question is only whether anything at all was ever imported.
const available = useMemo(() => places.some(p => (p.route_geometry?.length ?? 0) > 2), [places])
const clear = useCallback(async () => {
if (dayId === null || busy) return
setBusy(true)
setError(null)
try {
// Every leg of the day, so nothing is left behind on a leg the track never reached.
const legs = Array.from({ length: Math.max(0, stops.length - 1) }, (_, i) => i)
// null rather than nothing: dropping the points is also dropping the claim.
await vias.addMany(dayId, [], legs, null)
setOutcome({ vias: 0, strayKm: 0, capped: false })
} catch {
setError('save')
} finally {
setBusy(false)
}
}, [dayId, busy, stops.length, vias])
return { dayId, open, close, tracks, busy, round, error, outcome, apply, clear, viaCount, available, current, namesByDay }
}
@@ -0,0 +1,174 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { CorridorPoi, CorridorSearch } from './useCorridorPois'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
const { useCorridorPois } = vi.hoisted(() => ({ useCorridorPois: vi.fn() }))
vi.mock('./useCorridorPois', () => ({ useCorridorPois }))
import { useRoadtripCorridor } from './useRoadtripCorridor'
function poi(over: Partial<CorridorPoi> & { osm_id: string; name: string }): CorridorPoi {
return {
lat: 53, lng: 10, category: 'fuel', poi_type: 'fuel', address: null, website: null,
phone: null, opening_hours: null, cuisine: null, source: 'openstreetmap',
offRouteKm: 1, alongKm: 10, brand: null, brand_wikidata: null,
...over,
} as CorridorPoi
}
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 1, dayNumber: 1, date: null, title: null,
stops: [
{ assignmentId: 1, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
],
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
geometry: [[53.5, 9.9], [53.0, 11.0], [52.5, 13.4]],
distance: 0, duration: 0,
...over,
}
}
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
})
/** The search state the mocked hook hands back, results included. */
function searchWith(results: CorridorPoi[]): CorridorSearch {
return {
results,
progress: { done: 0, total: 0 },
loading: false,
capped: false,
failedAreas: 0,
truncatedAreas: 0,
error: false,
// The thinned line the day's geometry becomes; hits and stops are measured along it.
spine: [{ lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }],
search: vi.fn(),
clear: vi.fn(),
}
}
beforeEach(() => {
useCorridorPois.mockReset()
})
describe('useRoadtripCorridor', () => {
it('FE-ROADTRIP-CORRIDORSTATE-001: with no filter everything found is visible', () => {
const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
useCorridorPois.mockReturnValue(searchWith(hits))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
expect(result.current.visible).toBe(hits)
})
it('FE-ROADTRIP-CORRIDORSTATE-002: the filter matches part of a name, ignoring case', () => {
useCorridorPois.mockReturnValue(searchWith([
poi({ osm_id: 'a', name: 'Aral Autohof' }),
poi({ osm_id: 'b', name: 'Shell Nord' }),
]))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => result.current.setNameFilter('shell'))
expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
})
it('FE-ROADTRIP-CORRIDORSTATE-003: the brand counts as a name, because the server folds the operator into it', () => {
useCorridorPois.mockReturnValue(searchWith([
poi({ osm_id: 'a', name: 'Autohof Nord', brand: 'Shell' }),
poi({ osm_id: 'b', name: 'Raststätte Süd', brand: 'Aral' }),
]))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => result.current.setNameFilter('Shell'))
expect(result.current.visible.map(p => p.osm_id)).toEqual(['a'])
})
it('FE-ROADTRIP-CORRIDORSTATE-004: whitespace alone is not a filter', () => {
const hits = [poi({ osm_id: 'a', name: 'Aral' })]
useCorridorPois.mockReturnValue(searchWith(hits))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => result.current.setNameFilter(' '))
expect(result.current.visible).toBe(hits)
})
it('FE-ROADTRIP-CORRIDORSTATE-005: starting a new search clears the old question', () => {
useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => result.current.setNameFilter('aral'))
expect(result.current.nameFilter).toBe('aral')
// A search begins: keeping the term would hide the new hits behind the old query.
useCorridorPois.mockReturnValue({ ...searchWith([]), loading: true })
rerender()
expect(result.current.nameFilter).toBe('')
})
it('FE-ROADTRIP-CORRIDORSTATE-007: typing while the search is still running survives the next batch', () => {
// Hits arrive box by box and publish as they land. Clearing on every batch instead of
// on the start of a search wiped out whatever had been typed in the meantime.
useCorridorPois.mockReturnValue({ ...searchWith([poi({ osm_id: 'a', name: 'Aral' })]), loading: true })
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => result.current.setNameFilter('shell'))
useCorridorPois.mockReturnValue({
...searchWith([poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]),
loading: true,
})
rerender()
expect(result.current.nameFilter).toBe('shell')
expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
})
it('FE-ROADTRIP-CORRIDORSTATE-006: the visible list keeps its identity while nothing changes', () => {
useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
const before = result.current.visible
rerender()
// Both map renderers rebuild every marker when this array's identity changes.
expect(result.current.visible).toBe(before)
})
it('FE-ROADTRIP-CORRIDORSTATE-020: narrowing to one point keeps only what is near it', () => {
// A seven-hundred-kilometre day turns up hits along the whole of it. Picking the point
// you mean to break at is a subtraction, because both figures are measured against
// the same thinned line rather than being asked for separately.
useCorridorPois.mockReturnValue(searchWith([
poi({ osm_id: 'near', name: 'Near', alongKm: 30 }),
poi({ osm_id: 'far', name: 'Far', alongKm: 600 }),
]))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => { result.current.setSectionKm(50) })
act(() => { result.current.setSection({ dayId: 1, kind: 'stop', index: 0 }) })
// Stop 0 sits at the start of the drive, so only the hit within 50 km survives.
expect(result.current.visible.map(p => p.osm_id)).toEqual(['near'])
})
it('FE-ROADTRIP-CORRIDORSTATE-021: an anchor belonging to another day narrows nothing', () => {
// The section names a day as well as an index, so switching days cannot silently
// apply yesterday's break to today's drive.
const hits = [poi({ osm_id: 'a', name: 'A', alongKm: 10 }), poi({ osm_id: 'b', name: 'B', alongKm: 600 })]
useCorridorPois.mockReturnValue(searchWith(hits))
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
act(() => { result.current.setSection({ dayId: 99, kind: 'stop', index: 0 }) })
expect(result.current.visible).toBe(hits)
})
})

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