Compare commits

..

96 Commits

Author SHA1 Message Date
Konstantinos Thermos 3c735c80a4 Merge remote-tracking branch 'origin/dev' into feat/trip-route-overview
# Conflicts:
#	client/src/components/Map/MapView.tsx
#	client/src/components/Map/MapViewGL.tsx
#	client/src/pages/TripPlannerPage.tsx
#	client/src/pages/tripPlanner/useTripPlanner.ts
2026-09-11 16:22:21 +03:00
Maurice a8e4c45576 feat(share): show a stop and a booking in full on a shared link, and no more (#2320)
A public link showed a stop as its name and one line under it, the address
or the description, whichever came first. The owner had written both, and
a note on the place and another on the day, and had set how long they meant
to stay and how to reach it. All of that shows now, laid out the way the
planner lays it out, and every link leaves the page in a new tab with no
opener. A booking shows the note the owner attached and the page they
linked, labelled by where it goes.

What travels to the page is chosen column by column now, where it used to
be the whole row. The whole row of a booking carried its confirmation
number, its import trail and its raw metadata, and the metadata is where an
imported ticket keeps its seat and its record locator; the whole row of a
place carried the owner's booking notes on it and the Google identifiers.
None of that belongs to somebody holding a link. Metadata keeps the parts
that describe the journey, by an allow-list, so a field the import starts
writing tomorrow does not become public by appearing. Links are accepted
only as http or https, on the server and again on the page.
2026-09-11 11:01:25 +02:00
Maurice 87c73e50fb feat(studio): give a book its first and its last page (#2317)
A new book was a cover, a spread and a back cover, so it opened straight
onto pages 2 and 3. A bound book does not: the inside of the cover is
blank, and the first thing facing the reader is a single right-hand page,
page 1. The same at the end, where a single left-hand page sits before the
back cover. Both are laid down now, empty, in a new book and in a laid-out
one, and they are fixed the way the covers are: an inner spread only ever
goes between them.

The page numbers follow. They were counted from a spread's position in the
array, which assumed the spread after the cover was the first, and three
places did that sum on their own. One function now counts along the book,
one page for a single leaf and two for a spread, so the sheet on screen and
the label in the print view and the folio on the page all say the same
number. A book with a first page starts at 1; a book from before the first
page existed has none and keeps the numbers it always had.
2026-09-11 11:01:15 +02:00
Maurice 1128bcd1c5 feat(journey): let the photo picker's calendar jump years (#2318)
The date range on the photo picker had a calendar that moved one month per
click and no other way to move. A trip three years back was thirty-six
clicks away. The header is a button now, the way the app's other calendar
works: click the month to see the year's months, click the year to see a
page of twelve years, and the arrows step whichever level is showing. A
pick steps back down, and reopening starts on the days again. The same
component serves the entry editor, which keeps its trip-day dots.
2026-09-11 11:01:04 +02:00
Maurice 7946c2f749 fix(maps): measure a booking line along the road before hiding it (#2275)
A short transport hop is hidden once its two ends sit closer than a few
dozen pixels, so a booking that would draw as a dot does not clutter the
map. That check compared the endpoints, which was the whole line while
every hop was straight. A car booking is routed along the road now, and a
road that leaves a town, rounds a lake and comes back to the next town along
can run across half the screen while its ends project sixty pixels apart.
The whole drive vanished until the map was zoomed far enough in for the
ends to separate, which is the gap in the route the report shows.

The decision now walks the line that will actually be drawn, road route or
arcs, and keeps the hop as soon as that reaches the floor. A straight
two-point hop measures exactly what it measured before, so every existing
threshold holds for the case it was written for. One helper serves both
renderers, which also folds the two hand-copied floor tables into it.
2026-09-11 11:00:57 +02:00
Maurice 5ef60f5641 fix(todo): describe the MCP priority the way the app reads it (#2297)
The create_todo and update_todo tools said 1 was low and 3 was high. The
app, its sort and the wiki all say the opposite: 1 is P1, the red one at the
top of the list. An assistant that followed the description filed every
urgent task as the least urgent. The words now match the badges, and the
update tool, which said nothing at all, says the same and that null clears
it. No stored value changes.
2026-09-11 11:00:45 +02:00
Maurice 9ce8b7ea9e test(roadtrip): cover the fold, the seam and the live via, and fix what that found
Cases appended to four existing suites for the lines this branch left unrun: the
fold state and the map places it hides, the owner-based via anchor, the day seam
and the proportional geometry slicing, and the live roadtripVia:changed events.
With the four new suites in the commits before this one, coverage of the lines
this branch adds goes from 69 % to 96 %, measured the way the gate measures it —
the routed lines against the diff, not a file average.

Every added case was mutation-checked: the rule it claims to pin was broken in
the source and the case had to fail. Two of them found real defects rather than
missing coverage, and both are fixed here.

A via dropped on the incoming night drive went to the wrong leg. The guard for
it asked the insert index whether the point lay before the card's first stop,
and that index cannot answer: insertIndexForAlong clamps to at least 1 for any
card with two stops or more, and the rail publishes no other kind. So the branch
read correctly and never ran, and a via on the night stretch was filed after the
first drawn stop — the route ran forward, doubled back to the point and carried
on, which is exactly what the comment above it said must not happen. Asked of
the distance instead.

The two one-field writes the rail offers, the stop kind and the fill level, went
straight to the API while their neighbour checked place_edit first. The server
refused them, so nothing was ever saved, but a reader got an error toast for
touching a control that should not have acted.
2026-09-10 21:40:51 +02:00
Maurice a9907e2f10 feat(plugins): let a plugin answer place searches from its own index
TREK searches its own place index and OpenStreetMap. Neither carries a rating,
so "the best rated hotel around here" has never been answerable, and an operator
with a better index for their region had no way to reach the search box at all.
The searchProvider hook is that way in: a plugin is handed the query, a limit,
the language and the coordinate the search is biased toward, and answers with
places in the same shape the core search returns, plus a rating.

It sits beside the core search rather than inside it. The plugin runtime already
imports PlacesModule, which imports MapsModule, so asking the providers from the
maps service would close a module cycle — the same reason the other fourteen
contribution controllers stand next to what they contribute to. The client asks
both at once and merges, at the single point every caller already goes through,
so all nine search surfaces get plugin hits without knowing the hook exists.

A plugin index may never hold up the list: the client stops waiting at two and a
half seconds and the host gives a provider two, which is also why the budget is
the shortest of any hook here. With no such plugin installed the route answers
without reaching the runtime at all.

Every hit is normalized host-side and shared by the REST route and the MCP tool,
so a cap that holds for the browser holds for the assistant: coordinates are
range-checked, strings capped, the rating clamped to 0..5, a website must be
http or https, and ids are namespaced so they can never collide with an OSM one.

search_places_via_plugins is the MCP half, because what the user can see the
assistant has to be able to see; search_place now points at it.
2026-09-10 21:40:51 +02:00
Maurice a00cc889ed feat(roadtrip): show every change to a road trip live to everyone on it
Two people shaping one drive were each editing their own copy: a via dragged on
one screen reached the other only on the next reload, and the route it changed
looked settled until then. Every write in the domain now announces itself over
the websocket the rest of the trip already uses — the five REST routes and the
matching MCP tools alike, so an assistant moving a stop is as visible as a
person doing it.

Two new trip-scoped events carry it: roadtripVia:changed with the day's whole
via list, and roadtripTrack:changed. The whole list rather than a delta, because
a via is identified by its position in a chain and a delta protocol would need
an ordering guarantee to be worth anything.

The originating socket is excluded through the X-Socket-Id header the other
domains already forward, so nobody watches their own change arrive twice.
2026-09-10 21:40:51 +02:00
Maurice 3f0eb210d8 feat(roadtrip): move a stop reached after midnight onto the day it is reached
A day that runs past midnight used to keep every one of its stops on its own
card, so day one ended at 03:12 with three places nobody reaches on day one and
day two began with a drive that had already happened. The rail now lays the
stops out by the date they are actually reached: when the clock goes backwards
between two arrivals the ones after it move to the following day, into a block
that says where they came from, and the night drive and its kilometres go with
them. It is an arrangement, not a write — the day planner beside it is
untouched, nothing reaches the database, and a shorter stay the evening before
puts everything back on its own.

The map follows the same arrangement, because a boundary drawn in the rail and
not on the map is two answers to one question. Day one now ends where its last
reachable stop is and the line, the legs and the totals are cut to match.

Two switches in the driving settings, off by default. "Connect the days" routes
the gap between one day's last stop and the next day's first, which is real
driving the rail has never drawn, and counts it. "A colour per day" then puts
the days back into a line that has become continuous, and tints each day header
to match.

Collapsing a day from its header takes its route and its places off the map as
well as out of the rail, and the categories in the corridor panel fold the same
way. The refuel band is one full-width red band with the pump as its button,
the results are chips, and the driving dialog is two columns of panels with the
figures on one right edge.

Also fixes a via that would not adjust the route once dragged. Five separate
causes, all measured rather than guessed: leg threading used the card index
instead of the owning day's, the anchor lookup returned chain indices, it
filtered by the card rather than by the anchor's owner, seams were routed
without their vias, and a seam once routed was never re-fetched.
2026-09-10 21:40:51 +02:00
Maurice bd6e6312e1 fix(roadtrip): keep a typed range when the parts round away to nothing
A consumption typed as 500 rather than 5.0 makes the tank arithmetic round to a
range of zero. Zero is a number, so the `??` in effectiveRangeKm took it as the
answer and discarded the range the traveller had typed by hand next to it, and
useVehicleRange then folded that zero to null. What reached the rail was "no
range at all": every fuel and charge warning went quiet, with nothing on screen
naming the field that did it.

A range that rounds below one kilometre is a typo rather than a car, so it is
reported as no answer and the typed figure gets its turn.
2026-09-10 21:40:51 +02:00
Maurice 96ab748f34 feat(roadtrip): let one stop say how full it fills
The fill level was one figure for the whole trip, and a trip does not have one.
A motorway rapid charger is worth about 80 % because the last fifth costs as
long again; the charger at the hotel is worth all of it because the car stands
there all night. Between those two the difference is a whole leg, and a single
setting has to be wrong about one of them.

So the badge on a refuelling stop is now the way in: it shows what that stop
will actually fill to, its own figure or the traveller's, and opens a panel of
the six answers people actually give. Under them it says what the choice buys in
kilometres, because 80 % is an abstraction and "about 290 km from here" is the
thing being decided. Picking the figure a stop already has hands it back to the
setting, the same gesture the kind picker uses to un-pick a kind.

The value lives in places.fill_percent, beside stop_type, because it is a
property of the stop rather than of the person. Null means "follow the setting",
which is every stop that predates the column and every stop nobody has an
opinion about, so nothing changes for a trip that never touches it.

Two pieces of shared machinery came out of it rather than being copied. The
popover chrome — measure before showing, flip when the window is short, close on
an outside click, Escape or a scroll — is one component now, used by the kind
picker and this one. So is the assembly of the range from seven settings, which
the routes hook and the rail both need and which would have drifted the first
time a field was added.
2026-09-10 21:40:51 +02:00
Maurice aaf6559d5d feat(roadtrip): state a range as the car it comes from
A range could only be typed as one number, and that is the number fewest
travellers actually know. A tank size is on the filler flap, a consumption is in
the manual, a battery capacity and a kWh per hundred are on the data sheet. All
of them divide into the same figure, so the dialog now takes either and does the
division itself. What the parts say wins over what was typed, because it is the
more specific answer, and the range row goes read-only when they do so there is
never a second answer to the same question.

Of ABRP's list only battery wear is here. Plug type, reference speed, relative
speed and drive style shape a consumption PREDICTION; this addon does not
predict one, it divides a capacity by a consumption the traveller states, so
those fields would add typing without moving a kilometre. Wear is different in
kind: it shrinks the capacity itself, and on an older car by enough to decide
whether the last leg of a day arrives.

Storage stays metric throughout, the same rule the range itself follows. An
imperial traveller types gallons, miles per gallon and kWh per 100 miles and
gets them back unchanged; mpg is the inverse of litres per hundred rather than a
scale factor, which a straight multiplication would have stored as a car four
times as thirsty.

The gauge under the picker is the argument that these two ways of stating a
range are the same thing, so it answers while the figures are still being typed
rather than after the field is left. It draws the tank the day it left the
factory: the filled part is what a stop actually puts back in, the hatched tail
is what age has taken off, and both are things the bare number cannot say.

Also here, from the same round: a fill-up that stops short of full. Nobody
charges to 100 % on the road, and counting every stop as a full tank overstated
everything after it by that last fifth. Stops that refuel now carry a badge
saying how far they fill.
2026-09-10 21:40:51 +02:00
Maurice 5b147af0b6 feat(roadtrip): tell petrol from electric, and shape the refuel band like a drive band
Four things that only showed up once it was on screen.

The range budget treated a petrol station and a charger as the same thing, so an electric
car pausing for coffee at a filling station had its battery refilled on paper: the
warnings went quiet for the rest of the day and the driver was told nothing. A vehicle can
now be named, and then only what actually refills it counts — and the search asks for that
kind alone instead of offering pumps to a car that cannot use them. Not saying keeps the
old behaviour, which is what everybody who never opens the dialog is on.

The band was its own shape in a rail made of drive bands. It is one now: the same rail,
the same height, the same type size, a warning tint instead of a ring, and the figures
where a drive band keeps them. The buttons are icons of the same 18px square the shuffle
mark uses, so the band stays one line beside its neighbours.

The old range warning stood down where the band shows. Both at once said the same thing
twice in the wrong order — the answer above the problem — because the warning marks the
stop somebody finds out at while the band sits on the leg the fuel ends on.

Two measured corrections to the search itself. The server turns a box into a circle of
half its diagonal and silently narrows anything past 20 km, so a box of ±25 asked for 35
and came back flagged as narrowed EVERY time, which read on screen as "we could not
check"; ±14 asks for 19.8 and is answered in full. And the circle is centred 12 km before
the dry point rather than on it, because only road already driven is any use — centred on
the point, half the circle was spent on road nobody can reach.

An answer that finds nothing now leaves the button rather than only a close. The place
search is a shared public service that does time out, and "it did not answer" with no way
to ask again reads as broken rather than as busy.
2026-09-10 21:40:51 +02:00
Maurice 95d1752a3d feat(roadtrip): suggest where to fill up, not just warn that you cannot
The range budget has always been able to say "you have driven 245 km on this tank" and
never "and here is where to do something about it". The review put it plainly: the value
is in the suggestion, not in the warning.

The warning sits on the stop the car ARRIVES at, which is where somebody finds out. The
fuel runs out earlier, often most of a leg earlier, and a filling station offered at the
warning is one the car cannot reach. So the dry point is computed where it actually is,
a search runs around it on request, and what comes back is filtered to what can be
reached.

Three filters, each one there because its absence gives a wrong answer rather than an
untidy one. Only what lies before the tank empties, because a pump past it is on the far
side of an empty tank. The detour counts double, because 8 km off the road is 16 km of
driving. And a reserve is kept back, because the range figure is a round number from a
manual, not a measurement.

Sorted by the detour rather than by how far it gets. Measured on a real day between
Hamburg and Berlin: the station that got furthest was 9.2 km off the road for one
kilometre more of range, while one 0.9 km off sat almost as late.

Deduplicated by name and half a kilometre, which also came out of that measurement. OSM
maps a charging site as one node per socket: the first three offers were the same
"autostrom plus GmbH" three times over. The four nodes sat in two pairs 6 m and 7 m
apart with 450 m between the pairs, while genuinely different branches of one chain were
18 to 36 km apart — so anything from 500 m upwards separates the two cases.

An empty answer says which kind of empty it is. "Nothing on the reachable stretch" after
a request that failed or was cut short states something that was never checked, and the
server flags both cases itself. Offline it does not offer at all: the place write queues
offline but the day assignment does not, so accepting one would leave an orphan.

Nothing is searched until it is asked for, and one press is one request against a small
circle rather than the sixteen boxes a corridor run costs. Accepting hands the choice to
the same popup a corridor hit goes through, so the stop kind, the dwell time, the
position, the via re-anchoring and the re-route are the paths that already work.

The dry point counts DRIVING metres only. The day's drawn line concatenates every run,
ferries and walks included, while the budget skips those, so walking that line to the
same figure would overshoot by the length of any crossing and put the marker out at sea.
2026-09-10 21:40:51 +02:00
Maurice c9e0fe52d5 wip(roadtrip): the point where the tank runs dry, as a place
deriveDriveWarnings knew the distance and threw the position away. The warning is filed
against the stop the car ARRIVES at, which can be most of a leg past the point the fuel
actually ran out — fine for a warning, useless for a suggestion, because a filling
station offered at the warning is one the car cannot reach.

emptyAt is a fourth field on the return object rather than a field on each warning, for
two reasons. Five tests assert warnings with toEqual and an extra defined property fails
them all. And a dry point is once per TANK while a warning is once per stop: a long
stretch with nothing on it warns repeatedly on purpose, and one refuel offer per warning
would stack three identical offers for one fill-up down a single day.

drivenMeters counts driving legs only, which is the whole reason it is counted here
rather than derived later. The day's drawn line concatenates every run, ferries and walks
included, while the budget skips those — so walking that line to the same figure
overshoots by the length of any crossing the day contains and puts the marker out at sea.
pointAtMeters walks the driving-only line instead.
2026-09-10 21:40:51 +02:00
Konstantinos Thermos bf1900c3a0 feat(maps): draw every travel day's route at once, with the trip total
Extract the day route builder out of useRouteCalculation into a pure module and
aggregate it over the whole trip: one map with every day's route, each day in its
own colour, the mode per stage and the trip's total distance. Straight lines first,
roads as they answer; a refused leg keeps its line and adds no distance.

Give the floating map controls one shared drop shadow while adding the toggle, so
the new pill and the base-layer switcher stand off the tiles the way the locate
button always has. Document the control in wiki/Map-Features.md.

Addresses #1736
2026-09-10 15:57:48 +03:00
Maurice 59b7c35e87 fix(roadtrip): re-pin a via to the leg it was dragged onto
Dragging a via somewhere else on the map changed where the point IS and never where it
sits in the chain. The anchor that decides which two stops it is threaded between was
worked out once, when the point was dropped, and the drag sent only coordinates —
`roadtripViaUpdateRequestSchema` carried a lat and a lng and nothing else.

So a via pulled past the stop it used to precede kept claiming the earlier leg. The
router then drove out to it and turned back before carrying on, which on screen is not a
subtly wrong route: it is a spur that reads as the drag having done nothing at all, with
the original road still sitting there unchanged.

The anchor is not a property of the point but of where the point sits along the drive, so
it is recomputed on a drag the same way it is on a drop. The projection that did that was
inline in addRoadtripVia; it moves to a shared `anchorFor` and both callers use it.

A drag differs from a drop in two ways, both deliberate. It measures against its OWN day:
letting the nearest day win, which is right for a click on open map, would hand the via to
a neighbouring day whose road happens to pass closer and make it disappear from the day it
was dragged in. And it has no 2 km guard: pulling a via well off the current road is
exactly what the gesture is for, and the anchor only says which leg gets bent.

`after_order_index` is optional on the way in, so a drag inside one leg and an older
client still send just the coordinates, and the stored pin is then left alone rather
than cleared.
2026-09-09 19:03:00 +02:00
Maurice 77162aa8d5 feat(roadtrip): avoid tolls, motorways and ferries, through a second engine
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.

FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.

Four things the docs would have got wrong, all measured against the live instance:

- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
  and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
  shape drags every intersection along. So the adapter translates instead of borrowing a
  familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
  snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
  radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
  it is split into runs overlapping by one point, which is exact rather than approximate:
  a measured six-stop day came back within two metres and under a second of the same day
  asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
  those times feed the arrival clocks of a day. So a day is answered by one engine or by
  neither, never by both, and a piece that will not answer loses the whole day rather than
  half of it — the schedule chains leg times blindly and would otherwise print a complete,
  plausible, silently wrong timetable.

Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.

Two things that would have broken quietly and did not:

- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
  studio and the booking geometry. Without the avoidance in the key, flipping a switch
  returns the old road out of cache in under a millisecond — and worse, an avoiding answer
  stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
  echoes the coordinates it was given and never says where it bound them, but the ends of
  a leg's line are exactly those points: measured against Füssen, its first vertex matches
  OSRM's snapped location to the digit.

The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.

Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
2026-09-09 19:03:00 +02:00
Maurice 29fc586930 test(maps): pin the coordinates a suggestion carries
The half of the change that saves the pick from a text search for its own
label was covered by nothing. MAPS-AUTO-010 reads `source` and stops there;
the client test writes the coordinates into its own mock and then asserts them.
Delete `lat: p.lat, lng: p.lng` from the service and both stay green, while the
fallback quietly goes back to searching for "Tokio Hauptbahnhof, 東京駅丸の内駅舎"
and taking whatever comes back first.

So the server test now asserts the coordinates on the wire for both indexes,
and the mobile sheet — which carries a copy of the same fallback and had no
test for it at all — gets one for the fallback and one for the source mark.
2026-09-09 18:55:31 +02:00
Maurice 7ba7338f5f fix(maps): sag bei jedem Vorschlag, welcher Index geantwortet hat
Seit die Tipp-Suche beide Schichten fragt, ist ihre Liste gemischt — die
Antwort nennt aber weiterhin EINE Quelle fuer die ganze Liste, und die heisst
'trek-places'. Der Client beschriftet damit jede Zeile, auch die aus
OpenStreetMap. Wer tippt, liest also acht Mal "TREK" und schliesst daraus, die
OSM-Schicht antworte gar nicht. Sie antwortet, sie steht nur unter falschem
Namen da. Auf der Suchstrecke daneben stimmt es laengst: dort traegt jeder Ort
sein eigenes `source`, und genau deshalb liest sich diese Liste richtig.

Also traegt es jetzt auch die Zeile. Das Feld ist optional, weil Google und der
Nominatim-Rueckfall je aus einer Quelle antworten; eine Zeile ohne eigene
Angabe faellt weiter auf den Namen der Liste zurueck.

Dazu die Koordinaten, die der Vorschlag ohnehin schon kennt. Bisher wurden sie
weggeworfen, und wenn der Details-Aufruf nicht antwortete — Kill-Switch, ein
ueberlasteter Overpass-Spiegel, irgendein Fehler oben — baute der Client eine
Textsuche aus Name und zweiter Zeile. Bei einer OSM-Zeile ist die zweite Zeile
aber keine Adresse, sondern der ortsuebliche Name: gesucht wurde dann nach
"Tokio Hauptbahnhof, 東京駅丸の内駅舎", und dessen erstes Ergebnis wurde
kommentarlos als der Ort genommen, den der Nutzer angeklickt hatte. Jetzt
nimmt der Rueckfall den Punkt, den die Zeile mitbringt.

Und das Abzeichen gibt es endlich auch mobil. Die Marke und ihre Tabelle
liegen dafuer in client/src/utils/placeSource.ts, weil beide Oberflaechen
dieselben Listen zeigen und ein Zeichen, das nur auf einer davon steht,
schlimmer ist als keines.

Was hier NICHT drin ist, obwohl es dazugehoert: dass der richtige Bahnhof in
der Liste steht. Das liegt am Dienst, nicht an TREK, und ist dort gemessen und
behoben — Rangfolge nach Art des Ortes plus die Verkehrsknoten, die der
OSM-Schicht ganz fehlten.
2026-09-09 17:34:05 +02:00
Maurice 2f71549413 fix(maps): let the imagery through the CSP, so the GL switch does something
The satellite toggle has worked on Leaflet for as long as it has existed and did
nothing at all on a GL map, which is the default renderer since the vector basemap
landed. Not for want of the layer: connect-src never named server.arcgisonline.com.

Leaflet asks for a tile as an <img>, and img-src waves every https: host through,
so the gap could sit there unnoticed for as long as Leaflet was the only renderer
that showed the imagery. A GL map reads the raster through fetch to hand it to
WebGL, and that lands on connect-src instead, where the host is missing. The
browser refuses it in the document, which is the worst shape a failure can take
here: the switch flips, the layer goes on, the source is there, and no tile ever
arrives. Nothing in the app can see it either, because a CSP refusal is not an
error the fetch can catch.

The prefetcher asks for the same tiles the same way, with mode 'no-cors', which
relaxes CORS and not CSP, so the imagery was never cached for offline use either.

Same shape as #2180, which added the other two raster presets for exactly this
reason and did not reach this host.
2026-09-09 16:57:10 +02:00
Maurice fab3d2bdf8 fix(maps): ask the OpenStreetMap layer on the autocomplete path
Typing "Tokio station" from Tokyo suggested a weigh station in Ritzville and a
station in Tuxtla Gutiérrez, and typing "Tokyo Station" put Japan in seventh
place behind Markham, Arlington, Calgary, Milano and Melbourne. The station
itself was never in the list.

The service has had the answer all along. Its OpenStreetMap layer carries 14.6M
travel places with every name form each is known under, including the exonyms a
traveller actually types, and it is not the same data as the index: Overture
holds businesses, so a search for a station finds the shops inside it. The layer
returns "Tokio Hauptbahnhof" for the German spelling and 東京駅 for the Japanese.

It was never asked. `sources` defaults to the index alone and trekPlacesSearch
never sent the parameter, so no caller here has ever seen a layer row.

Sent on the autocomplete path only. The explicit search asks Nominatim in
parallel and would get the same OpenStreetMap places twice; this path asks
nobody else, because Nominatim's usage policy names autocomplete as
unacceptable use. So the layer is not a second opinion here, it is the only
place those names live.

A layer row has a different shape, and that shape is why this is more than one
parameter. It has no `gers` and no address block, and its id reads
`osm:node/9712313` where this file resolves `node:9712313`. Passing it through
untranslated would hand the client an id getPlaceDetails does not know, and the
failure would land after the user had already picked the suggestion. The
secondary line falls back to the local name, which is what is written on the
building and more use under a translated label than an empty line.

Measured against the service before the change, same query, same coordinates:

  "Tokio station"  index only  -> Tokio Weigh Station, Ritzville US
                   with layer  -> Tokio Hauptbahnhof in second place
  "東京駅"          index only  -> six restaurants inside the station
                   with layer  -> 東京駅 itself in the list
2026-09-09 16:06:19 +02:00
Maurice 00cac02665 feat(maps): satellite on the GL maps too, not just Leaflet
The imagery toggle has been there for a while and only ever worked on Leaflet, so
anyone on MapLibre or Mapbox — which is the default since the vector basemap landed —
had a switch they could not see and a view they could not reach.

Leaflet swaps its whole tile layer for the imagery. A GL map cannot: its basemap is a
style with dozens of layers in it. So the same tokenless ESRI source goes on as a
raster layer of its own, inserted before the first layer TREK draws, which leaves the
route, the pins and the tracks on top and legible. Switching back hides it rather than
tearing it down, so going to and fro does not re-fetch a tile.

Built on demand rather than once at load, because a basemap change rebuilds the style
from scratch and takes every source with it; the styledata event is subscribed for the
same reason. The switch reads the setting the Leaflet map already stores, so the choice
survives an instance changing renderer, and it sits in the same corner with the same
offsets.
2026-09-09 15:57:46 +02:00
Maurice 756e0fb908 fix(places): let the pool follow the open day, and say that it does
The map has narrowed to the selected day on the Planned filter since #2024. The pool
beside it did not, so a trip with 55 planned places read 55 in the tab and drew five
pins on the map, with nothing anywhere to say the two were answering different
questions. Reported twice in the same thread as the map being broken: you pick a day,
open Places, tap Planned, see everything you ever planned, and the map shows a handful
of it.

The list and the tab count now use the same set the map does. Unplanned deliberately
stays trip-wide: a place assigned to some other day is planned, whichever day happens
to be open.

A note under the tabs says so and closes the day when dismissed. It sits there rather
than over the map because a chip on the canvas is out of reach on a phone, which is
where this was reported from.
2026-09-09 15:50:47 +02:00
Maurice d30b916f9c feat(maps): count how much routing an instance actually does
Every route is calculated in the browser against the public FOSSGIS hosts, so the
server never sees a routing request and nobody has a number: not how many a real trip
costs, not how big they are, not how much already goes to a self-hosted engine.
Deciding whether TREK could run its own router without that number is guessing at the
bill, and the counters have to be collecting before the question comes up.

Counters, not a log. A row is a day, a profile, a request kind and an engine kind with
totals on it: how many requests, how many waypoints, roughly how far, how many were
refused. No query, no coordinate, no route, no user, no trip. Nothing leaves the
instance: its own database holds them and its own admin reads them at
GET /api/route-usage/summary.

The client tallies in memory and flushes in batches — on a timer, past a threshold and
when the page is hidden — because a counter that cost a request of its own would
measure the load by adding to it. Routing all funnels through one fetch in
RouteCalculator, so that is the only place instrumented.

On by default and switchable off with route_usage_enabled, unlike the shadow log next
door: that one stores what people typed, this one stores four integers about a day.
2026-09-09 14:52:50 +02:00
Maurice 88129e4546 feat(maps): search the view in road trip mode too
The category pill was switched off whenever road trip mode was on, which left the
corridor search as the only way to find anything. The two answer different questions
though — 'what is along this drive' against 'what is in view right now' — and looking
for a hotel at tonight's stop is exactly the second one. Furkot shows accommodation
at a stop for the same reason (disc #1797 thread).

Both searches now draw together. A place found by both appears once, as the corridor
knows it, because only that copy carries how far along the drive it sits, and the
merged list keeps its reference while nothing changes so the map does not rebuild
every pin on each render.
2026-09-09 14:52:50 +02:00
Maurice a46d3fe88a fix(i18n): reword three literal German road trip strings
'Wähle aus, was du brauchst, und suche' is a word-for-word rendering of 'Pick what
you need and search' that no German speaker would write; 'Wieder ein Ziel' is not a
sentence a button can carry; and 'Alle Leistungen' reads as services rather than
charging power in a dropdown of kilowatts.
2026-09-09 14:52:50 +02:00
Maurice 149b87fd47 feat(roadtrip): give the day boundary its own band, and flag a late pause
Three things the rail was quietly getting wrong at a day boundary.

A stop reached later than the time set on it showed a warning only when it carried
a number. The schedule restarts its chain at any anchor, whatever kind of stop holds
it, so the finding was computed for a fuel or charging halt too and then thrown away.
Both branches now draw it, from one component, and it sits in the badge row beside
the stay rather than on a line of its own.

The midnight crossing was a small pill floating in the middle of the rail, which read
as a badge belonging to the stop underneath. It is now a band across the full width
that breaks the dashed line instead of letting it run through, with the mascot asleep
in the gap. Both its animations stop under prefers-reduced-motion.

And the stay dialog said 'leave 01:00' for a departure the chain places on the next
day, because the clock wraps modulo 24 h. It now carries the same day marker the rail
has always shown beside such a time.
2026-09-09 14:52:50 +02:00
Maurice 15beb0ea40 feat(roadtrip): work back from the first pinned time
The schedule only ever walked forward, so a stop before the first pinned time stayed
blank. That is the wrong way round for how people plan: the museum opens at ten, so
when do we have to leave? Pinning the time on the second stop and reading the first
one's departure off the chain is the whole point of pinning it.

The chain now runs both ways — forward from the first known arrival as before,
backward for everything ahead of it, departure being the next arrival minus the
drive and arrival that minus the stay. It stops at a leg that never routed rather
than inventing a duration, and when working back crosses midnight the whole chain
shifts up a day so the earliest stop stays on day zero and the boundary is drawn
between the two stops instead of under the first one.
2026-09-09 14:52:50 +02:00
Maurice f283254bb0 fix(reservations): keep a car booking's stops when it is saved on a phone
The desktop form has offered stops between pick-up and return since the road trip
release (#1797). The phone sheet never knew about them: its car branch rebuilt the
endpoint list from from/to alone, and because the server replaces every endpoint row
of a booking, saving there silently dropped whatever the desktop had set.

Its car branch now reads the stop endpoints back into an editor and writes them out
again, the same shape TransportModal uses: location, time, reorder arrows, remove.
2026-09-09 14:52:50 +02:00
Maurice 8acfaea3c4 chore(deps): take the multer and nodemailer fixes for four HIGH CVEs
Docker Scout turned the release PR red overnight on advisories published
after the last green run:

  multer < 2.3.0      CVE-2026-82333, CVE-2026-77078, CVE-2026-77037
  nodemailer < 9.1.0  GHSA-2x7j-588g-ccc2

Both are patch-level resource-exhaustion fixes with no API change — multer
2.3.0 carries the same four dependencies as 2.2.0. multer resolves through
the root override (@nestjs/platform-express asks for 2.1.1), so the range
there has to move with it, and the server's own override list beside it.
2026-09-09 10:24:22 +02:00
Maurice a463246c1c fix(tests): let the spool handler stub carry a payload
`ReturnType<typeof of>` resolves against the zero-argument overload, so the
stub only accepted `Observable<never>` and SPOOL-001 could not hand it the
`of('ok')` it needs. tsc -p tsconfig.tests.json broke on it, which sank the
server preflight job and with it every coverage job that needs it.
2026-09-09 10:11:36 +02:00
Maurice 2a38e79431 test(collab): cover the image upload path and the spool cleanup
The chat images never reached a test: committing them to storage, taking them
back out when a later one fails, and dropping them again when the reply target
turns out to be gone. The interceptor that removes the spool when a request
dies before the handler had none either, and it sits in a domain pinned at 99%
function coverage. Both filters are pinned too, including the disguised
extensions that were the point of changing them.
2026-09-09 00:11:00 +02:00
Maurice 2d2f1e88bd fix(collab): sync shared links live, and teach the harnesses the new columns
The three collab:link events were broadcast and landed nowhere: the links
panel held its own list and never listened, so a link somebody else added
showed up on the next reload. It listens now, the same shape the notes panel
uses, and the events are classified in the client's WS policy, which is the
ratchet that caught this.

Four e2e harnesses build their own trip_files and one asserts the collab
feature set, so the new message_id column and the new links flag had to reach
them too. Two MCP unit tests pinned the old four-key feature object.
2026-09-09 00:11:00 +02:00
Maurice 1e2a5e3862 fix(shared): keep the link contract free of DOM globals
The URL check used `new URL(...)`, and this package compiles against lib
ES2022 alone so that it carries neither DOM nor Node globals, which is why the
shared typecheck refused it. `z.url()` plus a protocol test does the same job
with names the package actually has; z.url() on its own would take mailto: and
javascript:.

The three collab:link events also needed their fixtures and their place in the
pinned inventory count, the guard that stops the fixture map drifting from the
registry.
2026-09-09 00:11:00 +02:00
Maurice 7e3b9fd039 fix(collab): keep every panel on screen, and stop leaking spool and links
The desktop layout rendered notes and links as a pair and dropped whichever of
the two was on its own, so turning the new Links feature off on an otherwise
default install made the Notes panel disappear. Both rows are now split by kind
and rendered only when they hold something, and one function decides what a
panel id renders, because the branch that lost a panel did so by not listing it.

The message and its attachment rows are written in one transaction. The caller
has already committed the image bytes by then, so a message that landed without
them left those bytes with nothing pointing at them and nothing to sweep them.
Deleting one now drops the blobs after the rows are gone rather than before.

A body the Zod pipe rejects used to leave up to four spooled images on disk:
pipes run after interceptors, so multer had already written them and the
handler's own cleanup was never reached. An interceptor removes them on any
failure before the handler, which also covers every other schema mismatch, and
the length check that could never fire is gone.

Adding, pinning and deleting a link now says something when it fails instead of
raising an unhandled rejection, the list has real types, and the rejected-image
toast is translated in all 23 locales rather than shipping English everywhere.
The picking, previewing and clearing of chat images moved into one hook both
shells use: it was copied into two, and both rebuilt every preview on each
change and leaked an object URL per image per pick.

Tests: the four link routes end to end with the validation pipe the harness was
missing, the image filter against the disguised-extension case, and the layout
combinations that drop a panel.
2026-09-09 00:11:00 +02:00
Maurice 07bb899c50 fix(collab): close the image upload hole and the guard trap
The chat image filter believed the Content-Type the client put on the part.
The stored name keeps the extension of the name the client sent, and the
download route derives what it serves from that extension and sends it inline,
so pwn.html declared as image/png came back as HTML on our own origin. The
filter now checks the extension as well, against the blocked list and against
the types it claims to accept, because a per-route filter fully replaces the
module-level one it looked like it was adding to.

The multipart message route also kept its guard decorators. Guards run before
the interceptor, so a refusal went out while the client was still streaming up
to four images and the socket died as ECONNRESET rather than carrying the
error. The checks moved into the handler, where the other upload route in this
file already does them, and a test pins both refusals.

The three collab:link events were missing from the shared registry, which the
server typecheck refuses, and the admin Links switch could never be saved
because the Zod contract had no key for it. Both are in now. Migrations moved
to the end of the list and the column add uses the pragma guard rather than a
caught duplicate-column error.

The message-required string keeps its old wording for a request without a file
part, so a client from before this route took images sees what it always saw.
2026-09-09 00:11:00 +02:00
Godric Hood 9543fdc3f2 feat(collab): add shared links, chat images, and plugin fullscreen
Adds a Collab Links panel (add/pin/delete) with translations in every locale so tab and button labels are not raw i18n keys, chat image attachments (up to 4), and native fullscreen on plugin iframes. Includes Zod request DTOs and CollabPanel coverage for the Links tab.
2026-09-09 00:11:00 +02:00
Maurice 59a1756529 style(admin): lay the admin panel out in two columns and match the banner buttons
The User Defaults and Notifications tabs were one narrow column of cards on a
page that has no max width, so the right two thirds stayed empty while the page
scrolled for ages. Both now use the two-column grid the Settings tab already
has, grouped by subject rather than by height.

User Defaults splits into "Default User Settings" (colour mode, units, time
format, currency, booking codes) and a "Map" card holding template, keys,
routing base, preview and engine. The columns are deliberately uneven — the left
one never needs more than its widest option row, the right one holds four fields
whose values are 60-character tile URLs.

Notifications keeps the three channel switches together on the left, because they
all write the same notification_channels list, and puts the operator's own
targets on the right. The admin preference matrix moves in with them instead of
running full width underneath: it decides which of those channels each admin-only
event goes out over. Its label column gets minmax(0,1fr) so it wraps rather than
overflowing in the narrower column.

The transit provider was the one native <select> left on the Settings tab. The
option list of a native select is drawn by the OS, so it followed neither the
colour scheme nor the radius nor the user's text-size setting; it now uses the
CustomSelect the rest of the app uses, and skips the request when the option
already selected is picked again.

In the update banner, "View on GitHub" was a size smaller than "How to Update" on
every axis — 12px against 14px, a 14px icon against 16px, less padding, and 2px
shorter. Two buttons side by side read as one group, so that reads as a mistake
rather than as hierarchy. Same box now; the fill, the colour and the weight still
mark the primary action.
2026-09-09 00:09:34 +02:00
Maurice 4611f0ab7a test(files): give the e2e harness the column the receipt link adds
listFiles projects linked_budget_item_ids now, so the harness table it selects
from needs budget_item_id or every list call in that file answers 500. Same
gap the collab harness had.
2026-09-08 23:15:26 +02:00
Maurice 6a3d4b1281 fix(budget): stop receipts deleting files, and share the upload path
Attaching a receipt no longer lets the budget domain delete one. Trashing the
file was reachable from the REST route, the MCP tool and the plugin RPC, none
of which check file_delete, and a receipt id is any file on the trip, so anyone
who could edit an expense could trash a document they were not allowed to
touch. A concurrent edit could do it by accident: the client sends the full set
of receipt ids from a snapshot taken when the form opened, so saving an expense
someone else had just attached a file to moved that file to the trash. Removing
a receipt now removes the link and nothing else.

The link row is treated as shared, because it is. One row can tie a file to a
place and to an expense at once, so the sync clears only the receipt half and
leaves the rest, the foreign key is SET NULL rather than CASCADE, and a receipt
already in the trash keeps its link so restoring it comes back attached.

The upload and its rollback moved into one module both shells call. It was
copied into six places, and all six rolled back with the permanent-delete
route, which only accepts a file that is already in the trash and therefore
answered 404 every time: a failed save left every uploaded file on the trip,
attached to the expense, and the next attempt uploaded another copy. It now
trashes first, keeps the pending files for the retry, and says so when a file
could not be removed instead of swallowing it.

Escape in the receipt viewer no longer closes the expense form behind it, the
download fallback uses a real accent class rather than a utility that does not
exist, and the upload indicator is actually switched on.

Tests: the receipt link behaviour on both service paths, the four new branches
in the files service, the upload module, and the viewer.
2026-09-08 23:15:26 +02:00
dersimoezdag 06ecc5135c feat(budget): attach receipts and invoices to expenses 2026-09-08 23:15:26 +02:00
Maurice b2a196d1e7 style(planner): soften the empty-day add button
A solid outline and a whisper of accent instead of the dashed grey placeholder
it replaced, so an empty day reads as an offer rather than as something
switched off, without competing with the day rows around it.
2026-09-08 20:41:57 +02:00
Maurice f5d6af9c77 fix(planner): steady satellite pill, responsive places rail, day-scoped add
The satellite toggle sat on an offset that included --day-panel-h, so it hopped
every time the day panel was collapsed or expanded. On the desktop map that
panel is a centred card between the sidebars and never reaches the pill at the
left edge, so the lift only applies on a phone now.

In the places rail the two add buttons split the width evenly once a day is
open, and every button drops to its icon when the rail is dragged narrow. The
filter tabs span the rail and keep their own text widths. The count is a badge
across the rail; the search field got the outline the controls around it use.

An empty day offers a button that opens the place form pointed at that day
instead of only stating that nothing is planned. The day is cleared again when
the form is opened to edit, which it was not before.

The trip tab bar no longer carries a native title repeating its own label; the
full name moved to aria-label for the tabs whose label is abbreviated.

Three maps tests drove the index switch through app_settings, which no longer
holds it, and now move TREK_PLACES_ENABLED instead.
2026-09-08 20:41:57 +02:00
Maurice 9fa0ceaf49 feat(places): add a place straight into the day that is open
The places pool sits one click away from the plan, and a place you already know
belongs to today had to go through the day picker afterwards to get there. With
a day open, the add button splits: the left half still opens an empty form, the
right half opens one that assigns the place to that day on save.

The plumbing was already there. placeFormDayId has assigned a freshly created
place to a day since #1998; nothing in the places rail ever set it.

The main label shortens to "New place" while the split is out, so both buttons
fit side by side in the rail without either one truncating. The second button
stays mounted and collapsed rather than unmounted, so it has something to
animate out of: width, padding and opacity over 180ms, and nothing at all under
prefers-reduced-motion. Collapsed it is also out of the tab order and hidden
from screen readers, because a zero-width button is still focusable otherwise.

Both columns of the place form are 320px now. Two panels of different widths
flanking one form read as a mistake rather than as a hierarchy, and neither of
them is the more important one.
2026-09-08 20:41:57 +02:00
Maurice 900e4806ad fix(places): centre the empty states, and give the details column one too
Five states, one shape. EmptyState grows a `fill` option: the stack already
centres what it holds, but with no height of its own there was nothing to centre
inside, so every one of them sat pinned under the header of its column.

Optically centred rather than geometrically. The extra room goes underneath,
which puts the mascot slightly above the middle: in a column you can scroll,
dead centre reads as having fallen to the bottom the moment the list is short.

The details column's own placeholder joins them, with the idle look rather than
the sad one, because nothing being picked yet is a state and not a fault. Its
line is shortened to fit on one: "Pick a result to see more", in all 23
languages.

The corridor panel, the saved-places list and the place details all dropped
`compact`, so the five now read at the same size as the road trip rail.
2026-09-08 20:41:57 +02:00
Maurice 4a88dbcbcf fix(places): say where a result came from, and stop asking questions in the form
Six changes to the place picker and the card behind it, all of them about a
panel saying less and meaning more.

The TREK place index has no switch in the admin panel any more. It decides
whether a search leaves the instance at all, which is a property of the
deployment rather than a taste: an operator pins TREK_PLACES_ENABLED=false in
their compose file, and it cannot be turned off from a browser by whoever holds
an admin account that day. The switch never shipped, so nothing to migrate.

The standing "Using OpenStreetMap" notice above the search box is gone. It was
a sentence about configuration in the middle of a form about a place, and it
answered a question nobody asked while leaving the real one open. Each result
now carries a small mark instead: TREK, OpenStreetMap or Google. It sits on the
row rather than above the list, because a list can be two indexes interleaved
and a heading could only name one of them.

The saved-places filters open across the full width of the row. Two of them sit
side by side, only one can be open at a time, and list names were being cut at
about ten characters for no reason.

Three empty states use the mascot the rest of TREK uses. The place details panel
no longer tells the reader to go and ask an administrator for a Google key, the
saved-places list and the road trip rail no longer explain themselves in two
lines of grey text. That needed one new mood, `sad`: `sleepy` reads as falling
asleep, which is a different thing from finding nothing.

The Kind of stop field is out of the place form. It is a road trip concept, and
the road trip view already sets it from the stop's own number, where the choice
is next to what it changes.
2026-09-08 20:41:57 +02:00
Maurice 1761f2bcd5 fix(maps): keep a browser without WebGL out of the error screen
The default basemap has been a vector style since 4.1.0, and maplibre-gl builds
its WebGL context inside the Map constructor. A browser that refuses one throws
from there, and VectorBasemap attaches the layer inside an async effect, so the
throw became an unhandled rejection no boundary could see.

What actually took the page down was the teardown. Leaflet registers a layer
before onAdd runs, so the half-built one stayed on the map, and the bridge's
onRemove calls this._glMap.remove() without asking whether there is one. That
TypeError lands in a React cleanup, which is why the error appears on the way
out of a map rather than on the way in, and why it reads as "Something went
wrong" when a tab changes and "TREK could not start" when the route does.

The browser is now asked first, once per page load, with a one-pixel probe that
hands the context straight back. Without WebGL the basemap is OSM raster tiles
instead of an empty map, and the maplibre chunk, about a megabyte, is never
fetched. The teardown path is safe either way: a half-built GL layer gets a
no-op stand-in so the bridge can finish its own cleanup, and detaching never
throws, because unmounting a map must not fail just because its basemap did.

Reproduced against demo.liketrek.com with WebGL switched off, and verified with
the fix: 14 raster tiles drawn, no GL canvas, no error screen.

Closes #2288
2026-09-08 19:00:13 +02:00
jelly 8be594d34c fix(atlas): correct Guangdong Province name in region bundle 2026-09-08 16:36:47 +02:00
Luffy 5ac6ce40be fix(i18n): add translations for new notification event labels 2026-09-08 16:19:31 +02:00
Maurice 2d7889c237 fix(memories): give the photo picker a chronological order it can rely on
The picker got its photos back in whatever order the provider happened to
return them. It groups by day and appends pages lazily, so an unordered page
lands under the wrong heading and pushes photos out of the fold the reader
is looking at.

Immich now asks for order: desc on both the search and the album path. An
older server that does not know the property drops it silently rather than
failing, so each page is sorted again here; the album paths drain every page
before sorting and are covered outright. getAssetInfo reports mediaType, the
same mapping the listing already used, so the detail route and the picker
agree on what an asset is.

Synology gets no sort parameter upstream on either path. Search.Search
documents none, and _fetchSynologyJson maps every app code except 106/107/119
onto a 400, so a parameter one DSM build rejects would take listing down
entirely for that user. Both paths read every page anyway, so ordering the
mapped result costs nothing and cannot fail.

No media type for Synology yet, deliberately. streamSynologyAsset serves
every asset as a JPEG out of SYNO.Foto.Thumbnail, original included; there is
no video byte path. Labelling an asset a video would make the lightbox mount
a player on a still image, which is worse than the missing badge. The badge,
the byte path and the persisted media_type belong together in their own
change.

Five album cases in the Immich integration suite pinned the old arrival
order. Where the position was incidental they now look the asset up by id;
where the order is the point they state the chronological one.
2026-09-08 15:24:21 +02:00
Maurice cfae0a1f78 fix(journey): stop the load-more observer rebuilding itself on every render
ScrollTrigger kept onVisible in its effect dependencies, and both callers hand
in a fresh arrow on every render: the picker its loadMorePhotos, the gallery
its setVisibleCount step. So every unrelated re-render, a photo being ticked
included, disconnected the IntersectionObserver and observed again, and each
observe() replays the initial callback.

The callback moves into a ref. loading stays a dependency, because re-observing
once a page has landed is the only thing that still produces a callback when
the sentinel never left the 200px margin, so folding that into a ref too would
leave paging dead after the first page. FE-COMP-JSCROLLTRIG-009 pins that, 007
and 008 pin the rebuild itself; both are red on the old component.

Markup, classes and the signature are untouched.
2026-09-08 15:09:51 +02:00
Maurice 31061751b0 fix(client): sort the picker grid in a memo again, not inside the fetches
Sorting inside searchPhotos and loadAlbumPhotos looks equivalent and is not.

contextLocation is live editor state. The place search and "use my location"
write it while the picker stays mounted, and the picker key is only provider
plus date, so nothing remounts it. Open an entry with no location, let the
photos load, then search for Rome: the caption flips to nearest-first and the
grid does not move.

The appending search made it worse than that. It sorted only the page it had
just fetched before appending, so from page two on the grid was a sequence of
sorted blocks rather than a sorted list, and a photo from page two landed under
a day heading page one had already closed.

FE-JRN-PICKER-021 pins it and is red without the memo.

The video badge also gets z-10 and pointer-events-none. It shares a stacking
context with the city gradient that follows it, so the later sibling was
winning on the geotagged tiles; pointer-events-none is what the eight existing
video overlays in the journey code all set.
2026-09-08 14:58:07 +02:00
xvilo 559b7624d0 fix(client): show pending state on Immich picker Add button
Clicking Add gave no feedback while the async onAdd call was in
flight, and nothing stopped a second click from firing a duplicate
submit. Adds a local pending flag that disables the button and swaps
its label for a spinner until the call resolves.
2026-09-08 14:58:07 +02:00
xvilo 2de9de8c3a feat(client): add video indicator to Immich picker thumbnails
Picker tiles gave no visual cue that an asset was a video rather than
a photo. Adds a small play-icon badge (bottom-left corner, matching
the desktop/mobile gallery grid's video treatment) driven by the
asset's existing mediaType field. No video preview -- just the
indicator.
2026-09-08 14:58:07 +02:00
xvilo d609aefe8f fix(client): keep Immich picker photo order stable and chronological by default
sortProviderPhotos previously passed photos through unchanged whenever
there was no valid journey location, leaving order entirely up to
Immich's search API (not guaranteed chronological). It now falls back
to newest-taken-first.

The picker also re-sorted the entire accumulated photo list on every
render, so loading another page could reorder tiles already on screen
when a location context was set -- newly loaded photos could land
above the fold instead of at the bottom, making it look like nothing
happened. Each fetched batch (search page or album) is now sorted once
at fetch time and appended, so earlier pages never get reshuffled by
later ones.
2026-09-08 14:58:07 +02:00
xvilo 1792fc17d0 style(client): run prettier on Immich picker files before functional changes
No logic change; isolates formatting noise from the upcoming picker fixes
so those diffs stay reviewable.
2026-09-08 14:58:07 +02:00
Maurice 72935d7e40 test(admin): wait for the migration POST instead of reading it after the toast
FE-MOB-MSTOR-013 read migrationBody straight after the save toast. The toast
comes from the PUT, the migration POST is fired by the queue effect one commit
later, so the two are not the same tick. It only passed while that effect
happened to flush first, which on a loaded runner it does not.

The desktop twin FE-ADMIN-STOR-030 has waited here since 8069b8805; the mobile
file was left behind.
2026-09-08 14:39:34 +02:00
Maurice 569f0ca1a5 test(mobile-nav): follow the new default dock through the mobile suites
Three expectations still described the Vacay/Atlas dock. Two of them
asserted the old arrangement by name, so they were pinning the very thing
that changed rather than catching a regression.

FE-MOB-NAV-006 now watches Atlas take the demoted seat next to
Collections, and picks Atlas out of the popover in FE-MOB-NAV-011 for
the same reason. Two new cases cover what the change is actually about:
Journey sits in the dock when it is enabled, and an instance without the
addon still gets Vacay and Atlas.
2026-09-08 14:39:34 +02:00
Maurice 5d0d106c70 chore: merge main into dev to clear the release conflicts
Nine files conflicted between the two branches and the merge changes not
one of them: the result is byte-identical to dev. Six were the version
string alone, 4.2.1 against 4.2.0, which the release job rewrites to
4.3.0 on the way to main anyway. The other three were places where dev
already carried main's change plus more, so dev's side wins in each.

Nothing is lost. Thirty of the thirty-four commits on main are
cherry-equivalent to dev already; of the rest, two are version bumps,
one is an empty tag commit, and the RTL text plugin fix is in dev under
its own commit, verified by the files it added rather than by its
message.

Also drops the copy of the LLM-ceiling test that main carried twice under
two names, which the merge would otherwise have reintroduced.
2026-09-07 23:26:18 +02:00
Maurice c04e740ff1 fix(mobile-nav): dock Journey by default, move Atlas one tap further
Feedback from Discord: on a phone in the field the two things you reach
for are the itinerary and the journal, but the default dock spends its
two free slots on Vacay and Atlas. Journey sits under More, and most
people never open the navbar customiser to fix that.

DEFAULT_DOCK_IDS becomes a priority list instead of a membership test:
the first MOBILE_NAV_MAX_BAR entries that are actually enabled take the
slots. Journey ships disabled, so an instance that never turned it on
keeps the exact Vacay/Atlas dock it has today. Listing all four known
global addons also fills the dock when one of them is switched off,
where the old membership test left the slot empty.

Accounts that saved their own bar/more split are untouched: a non-empty
config still wins over the default, so nobody's arrangement moves.
2026-09-07 23:18:23 +02:00
github-actions[bot] 515398f8ee chore: bump version to 4.2.1 [skip ci] 2026-09-06 14:13:46 +00:00
Maurice e4f2eaf336 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 16:11:52 +02:00
Maurice 16fee0800d 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 16:11:52 +02:00
Maurice 888cd4ee08 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 16:11:52 +02:00
Maurice 8fe90d1783 fix(test): the card is labelled Total trip spend 2026-09-06 16:11:52 +02:00
Maurice 4cda3c4b2e 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 16:11:52 +02:00
Maurice bc85ec1eea 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 16:11:52 +02:00
Maurice 04c22b34af fix(test): assert the untouched GET properly
toMatchObject({ method: undefined }) does not say what it looks like it says.
2026-09-06 16:11:52 +02:00
Maurice 56a23cb809 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 16:11:52 +02:00
Maurice 251378be1d 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 16:11:52 +02:00
Maurice 973ce5f8ff 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 16:11:52 +02:00
Maurice bbc32310e9 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 16:11:52 +02:00
xvilo ad6e51c574 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 16:11:52 +02:00
xvilo 6da11994d9 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 16:11:52 +02:00
Maurice eef1f16836 test(e2e): locate the panel tabs by aria-label, the tab bar has a Plan button too 2026-09-06 16:11:52 +02:00
Maurice 4a8d77c59c 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-06 16:11:52 +02:00
Maurice a70c2eb9e0 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-06 16:11:52 +02:00
Maurice 99b040f684 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-06 16:11:52 +02:00
Xheldon 944e0a0970 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-06 16:11:52 +02:00
Maurice b9f69b0c76 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-06 16:11:52 +02:00
Maurice fcefc7e771 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-06 16:11:52 +02:00
Mohamed Elkmeshi 6f0a171c74 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-06 16:11:52 +02:00
Maurice 3e1c06a0ee 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-06 16:11:52 +02:00
Xheldon 1aafab83a5 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-06 16:11:52 +02:00
Maurice c1d0435480 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-06 16:11:52 +02:00
Maurice 798859517f 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-06 16:11:52 +02:00
RemEx d3fdf78db6 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-06 16:11:52 +02:00
Maurice 1b71b8633d 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-06 16:11:52 +02:00
Maurice d77f1c1758 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-06 16:11:52 +02:00
Maurice e09c0688ae 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-06 16:11:52 +02:00
Maurice 0e66ae72ca 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-06 16:11:52 +02:00
jubnl 61bef334d5 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-06 16:11:52 +02:00
jubnl fa406b708f plugin-sdk-v1.7.0 2026-09-03 17:30:31 +02:00
github-actions[bot] 09ce5cb733 chore: bump version to 4.2.0 [skip ci] 2026-09-03 14:24:43 +00:00
515 changed files with 21276 additions and 2240 deletions
+27
View File
@@ -387,6 +387,33 @@ describe('client > dev-only contract drift checks', () => {
)
})
it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => {
server.use(
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
http.get('/api/plugin-search', () =>
HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })),
)
// Appended, not interleaved: the core list keeps the order it earned.
await expect(mapsApi.search('Rome')).resolves.toEqual({
places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }],
source: 'openstreetmap+plugin:demo',
})
})
it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => {
server.use(
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
)
// The optional index being unwell must never cost the search that worked.
await expect(mapsApi.search('Rome')).resolves.toEqual({
places: [{ name: 'Core hit' }],
source: 'openstreetmap',
})
})
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
+14 -1
View File
@@ -413,9 +413,22 @@ describe('client > endpoint wiring', () => {
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
})
/**
* The one call in this file that is deliberately TWO requests, which is why it is not
* in the list above: a search asks TREK's own indexes and any installed search-provider
* plugin at the same time, and the caller gets one list back (#2221).
*/
it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => {
log = []
await mapsApi.search('Rome')
expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([
'GET /api/plugin-search',
'POST /api/maps/search',
])
})
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
await assertCalls([
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
+68 -5
View File
@@ -661,8 +661,6 @@ 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),
@@ -1145,6 +1143,50 @@ async function withCachedPlaces<T>(
}
}
/**
* What plugins implementing `searchProvider` found for the same query (#2221).
*
* Its own request beside the core one rather than a branch inside it: the core search
* is TREK's own indexes and must not wait on, or fail with, somebody's plugin. This
* answers with an empty list for every failure there is — no provider installed, one
* that timed out, a 404 on an older server, the network gone — because a search that
* breaks when an optional index is unwell is worse than one without it.
*/
/**
* How long a plugin index may keep the search list waiting, in milliseconds.
*
* The host gives a provider two seconds to answer and this gives the round trip a
* little more. It is a ceiling on the WAIT, not on the provider: with no search
* plugin installed the route answers immediately without reaching any of them, so
* the normal case costs one local round trip and nothing else.
*
* A deadline rather than patience, because the alternative is a search that feels
* broken. A list that arrives without an optional index is a smaller answer; a list
* that arrives four seconds late is no answer at all.
*/
const PLUGIN_SEARCH_DEADLINE_MS = 2500
async function pluginSearchPlaces(
query: string,
lang?: string,
near?: { lat: number; lng: number },
): Promise<Record<string, unknown>[]> {
const stop = new AbortController()
const timer = setTimeout(() => stop.abort(), PLUGIN_SEARCH_DEADLINE_MS)
try {
const r = await apiClient.get('/plugin-search', {
params: { q: query, lang: lang || 'en', lat: near?.lat, lng: near?.lng },
signal: stop.signal,
})
const places = (r.data as { places?: unknown })?.places
return Array.isArray(places) ? (places as Record<string, unknown>[]) : []
} catch {
return []
} finally {
clearTimeout(timer)
}
}
export const mapsApi = {
/**
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
@@ -1154,10 +1196,25 @@ export const mapsApi = {
* 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.
*
* Plugin indexes are asked at the same time and their hits are appended, so every
* caller of this one function gets them without knowing they exist. Appended rather
* than interleaved: the core list is ordered by relevance and has earned that order,
* and a plugin's row carries its own `source` for a caller that wants to mark it.
*/
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'))),
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
// Side by side, so the wait is the slower of the two rather than their sum. Only
// the core call may reject: that rejection is what hands withCachedPlaces the
// offline path, and a plugin failure must never trigger it.
const [core, extra] = await Promise.all([
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
pluginSearchPlaces(query, lang, locationBias),
])
if (extra.length === 0) return core
const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+')
return { places: [...core.places, ...extra], source: `${core.source}+${from}` }
}),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
withCachedPlaces(
input,
@@ -1384,13 +1441,19 @@ export const collabApi = {
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
getLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/links`).then(r => r.data),
createLink: (tripId: number | string, data: { title: string; url: string; pinned?: boolean }) => apiClient.post(`/trips/${tripId}/collab/links`, data).then(r => r.data),
updateLink: (tripId: number | string, id: number, data: { title?: string; url?: string; pinned?: boolean }) => apiClient.put(`/trips/${tripId}/collab/links/${id}`, data).then(r => r.data),
deleteLink: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/links/${id}`).then(r => r.data),
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest | FormData, opts?: UploadOptions) => data instanceof FormData
? postMultipart(`/trips/${tripId}/collab/messages`, data, opts)
: apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
+24
View File
@@ -0,0 +1,24 @@
import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared'
import { apiClient } from './client'
/**
* Reports the routing counters this browser has tallied up.
*
* Fire and forget: it never throws and never blocks the route it is describing. A
* failed report is a lost count, which is a far smaller problem than a map that waits
* on a measurement.
*
* No client-side switch to check, unlike the shadow log. Counting is on unless an
* operator turned it off, and that is a server setting the server enforces — a report
* to a switched-off instance is answered with `recorded: false` and costs one request.
*/
export const routeUsageApi = {
report: (entries: RouteUsageEntry[]): Promise<void> =>
entries.length
? apiClient.post('/route-usage/report', { entries }).then(() => undefined)
: Promise.resolve(),
/** Admin only. The figures behind "could we host a router ourselves". */
summary: (): Promise<RouteUsageSummaryResult> =>
apiClient.get('/route-usage/summary').then(r => r.data),
}
+8
View File
@@ -20,6 +20,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'collab:note:created',
'collab:note:updated',
'collab:note:deleted',
'collab:link:created',
'collab:link:updated',
'collab:link:deleted',
'collab:poll:created',
'collab:poll:voted',
'collab:poll:closed',
@@ -27,6 +30,11 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'collab:message:created',
'collab:message:reacted',
'collab:message:deleted',
// Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is
// routed through live in that hook and nowhere else; a store slice for them would be a
// second copy of the same list to keep in step.
'roadtripVia:changed',
'roadtripTrack:changed',
// In-app notifications — hooks/useInAppNotificationListener
'notification:new',
'notification:updated',
+2 -1
View File
@@ -74,11 +74,12 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
}
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean }
const COLLAB_SUB_FEATURES = [
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
] as const
@@ -262,6 +262,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Settings2 } from 'lucide-react'
import { Map as MapIcon, Settings2 } from 'lucide-react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -44,6 +44,7 @@ type Defaults = {
map_tile_url?: string
carto_api_key?: string
routing_base_url?: string
valhalla_base_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
@@ -121,6 +122,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [routingBase, setRoutingBase] = useState('')
const [valhallaBase, setValhallaBase] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
@@ -131,6 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setRoutingBase(data.routing_base_url || '')
setValhallaBase(data.valhalla_base_url || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
@@ -154,6 +157,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'routing_base_url') setRoutingBase('')
if (key === 'valhalla_base_url') setValhallaBase('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
@@ -223,11 +227,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
<div>
<p className="text-sm text-content-faint mb-4">
{t('admin.defaultSettings.description')}
</p>
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
the map fields sat a screen below the units while the right half of the page
stayed empty. Grouped by subject rather than by height — what a user ends up
seeing on the left, everything that configures the map on the right.
The columns are deliberately uneven: the left one never needs more than the
widest option row, while the right one holds four text fields whose values
are 60-character tile URLs. Only the column gap is set, because Section
carries its own bottom margin and a row gap would double it once the grid
collapses. Two cards can sit straight in the grid; a third would need the
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
<div className="grid grid-cols-1 gap-x-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
{/* Color Mode */}
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
{([
@@ -325,7 +341,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</OptionButton>
))}
</OptionRow>
</Section>
<Section title={t('settings.map')} icon={MapIcon}>
{/* Map Tile URL */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
@@ -395,6 +413,28 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
</div>
)}
{/* The second engine, next to the first because it is the same kind of decision
and carries the same restart caveat. Only ever asked to leave a road class
out of one leg, which is the one question the OSRM above refuses. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.valhallaBase')}
<ResetButton field="valhalla_base_url" />
</label>
<input
type="text"
value={valhallaBase}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValhallaBase(e.target.value)}
onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })}
placeholder="https://valhalla.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.valhallaBaseHint')}</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, {
@@ -520,6 +560,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</div>
)}
</div>
</Section>
</Section>
</div>
</div>
)
}
+156 -7
View File
@@ -1,6 +1,6 @@
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -8,10 +8,11 @@ import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { saveWithReceipts } from './receiptUploads'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
import { downloadBlob } from '../../utils/fileDownload'
import { downloadBlob, openFile } from '../../utils/fileDownload'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -19,7 +20,8 @@ import { localToday } from '../Planner/today'
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import type { BudgetItem } from '../../types'
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetItem, BudgetItemReceipt } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
import { NumericInput } from '../shared/NumericInput'
@@ -84,6 +86,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<BudgetItem | null>(null)
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
// One note open at a time: two expanded rows next to each other read as a mess,
// and the point of the collapse is that the list stays scannable.
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
@@ -492,6 +495,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
)}
{previewReceipts && (
<ReceiptPreviewModal
receipts={previewReceipts.receipts}
initialIndex={previewReceipts.initialIndex}
onClose={() => setPreviewReceipts(null)}
/>
)}
<style>{`
.costs-root {
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
@@ -762,6 +773,32 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.unfinished')}
</span>
)}
{(e.receipts || []).length > 0 && (
<button
type="button"
onClick={(ev) => {
ev.stopPropagation()
setPreviewReceipts({ receipts: e.receipts!, initialIndex: 0 })
}}
title={t('costs.viewReceipt')}
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-all"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '2px 8px',
borderRadius: 999,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
fontWeight: 600,
flexShrink: 0,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
<Receipt size={12} className="text-content-muted" />
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
</button>
)}
</div>
{cur !== base && (
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
@@ -1122,6 +1159,24 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return m
})
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>([])
const [uploadingReceipt, setUploadingReceipt] = useState(false)
const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
if (!files || files.length === 0) return
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
}
const handleRemoveReceipt = (receiptId: number) => {
setReceipts(prev => prev.filter(r => r.id !== receiptId))
}
const handleRemovePendingReceipt = (index: number) => {
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
}
const [saving, setSaving] = useState(false)
const isTicketMode = splitMode === 'ticket'
@@ -1318,12 +1373,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
else await addBudgetItem(tripId, data)
setUploadingReceipt(pendingReceiptFiles.length > 0)
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
editing
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
))
// Only cleared once the save went through, so a retry after a failure
// does not upload a second copy of every file.
setPendingReceiptFiles([])
onSaved()
} catch {
toast.error(t('common.unknownError'))
} catch (err) {
// A receipt the rollback could not remove is still on the trip, and the
// user is the only one who can clear it out of the Files tab.
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
} finally {
setUploadingReceipt(false)
setSaving(false)
}
}
@@ -1633,8 +1699,91 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
placeholder={t('costs.notePlaceholder')} maxLength={NOTE_MAX}
className={inputCls} style={{ borderRadius: 10, padding: '10px 13px', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', outline: 'none', resize: 'vertical', minHeight: 68, width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', lineHeight: 1.5 }} />
</div>
<div className={panelCls}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.receiptsTitle') || t('costs.receipts')}</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>
<input
type="file"
multiple
accept="image/*,application/pdf"
style={{ display: 'none' }}
onChange={e => {
handleReceiptFileSelect(e.target.files)
e.target.value = ''
}}
/>
<span className="bg-surface-card border border-edge text-content-muted hover:text-content transition-colors" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 999 }}>
<Plus size={13} /> {t('costs.attachReceipt')}
</span>
</label>
</div>
{uploadingReceipt && (
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-caption, 1))', marginBottom: 8 }}>
{t('common.saving')}...
</div>
)}
{receipts.length === 0 && pendingReceiptFiles.length === 0 ? (
<div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', padding: '6px 0' }}>
{t('costs.noReceipts')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{receipts.map((r, rIdx) => (
<div key={r.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
<button
type="button"
onClick={() => setModalPreviewReceipts({ receipts, initialIndex: rIdx })}
className="text-content hover:underline"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, background: 'none', border: 0, padding: 0, cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
>
<Receipt size={14} className="text-content-faint flex-shrink-0" />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.original_name}</span>
</button>
<button
type="button"
onClick={() => handleRemoveReceipt(r.id)}
title={t('costs.deleteReceipt')}
className="text-content-muted hover:text-red-500 transition-colors"
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
>
<Trash2 size={14} />
</button>
</div>
))}
{pendingReceiptFiles.map((file, idx) => (
<div key={idx} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
<Paperclip size={14} className="text-content-faint flex-shrink-0" />
<span className="text-content" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</span>
</div>
<button
type="button"
onClick={() => handleRemovePendingReceipt(idx)}
title={t('costs.deleteReceipt')}
className="text-content-muted hover:text-red-500 transition-colors"
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
{modalPreviewReceipts && (
<ReceiptPreviewModal
receipts={modalPreviewReceipts.receipts}
initialIndex={modalPreviewReceipts.initialIndex}
onClose={() => setModalPreviewReceipts(null)}
/>
)}
</Modal>
)
}
@@ -0,0 +1,99 @@
// FE-BUDGET-PREVIEW-001 to FE-BUDGET-PREVIEW-008: the receipt viewer that opens
// on top of the expense form.
import { vi } from 'vitest'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetItemReceipt } from '../../types'
const authUrl = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (...a: unknown[]) => authUrl.get(...a) }))
function receipt(over: Partial<BudgetItemReceipt> = {}): BudgetItemReceipt {
return {
id: 1,
filename: 'stored.jpg',
original_name: 'lunch.jpg',
mime_type: 'image/jpeg',
file_size: 100,
url: '/uploads/files/stored.jpg',
...over,
} as BudgetItemReceipt
}
beforeEach(() => {
authUrl.get.mockReset().mockResolvedValue('/signed/stored.jpg')
})
describe('ReceiptPreviewModal', () => {
it('FE-BUDGET-PREVIEW-001: renders an image receipt once the signed url is in', async () => {
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', '/signed/stored.jpg'))
})
it('FE-BUDGET-PREVIEW-002: renders a PDF in a frame rather than an image', async () => {
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/pdf', original_name: 'bill.pdf' })]} onClose={vi.fn()} />)
// The viewer renders through a portal, so it lives on document.body.
await waitFor(() => expect(document.body.querySelector('object[type="application/pdf"]')).not.toBeNull())
expect(screen.queryByRole('img')).toBeNull()
})
it('FE-BUDGET-PREVIEW-003: a type it cannot show falls back instead of rendering an empty frame', async () => {
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/zip', original_name: 'receipts.zip' })]} onClose={vi.fn()} />)
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
expect(document.body.querySelector('object')).toBeNull()
expect(screen.queryByRole('img')).toBeNull()
expect(screen.getAllByText('receipts.zip').length).toBeGreaterThan(0)
})
it('FE-BUDGET-PREVIEW-004: a rejected signed url leaves the viewer up rather than blank-screening', async () => {
authUrl.get.mockRejectedValue(new Error('403'))
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
expect(screen.getAllByText('lunch.jpg').length).toBeGreaterThan(0)
})
it('FE-BUDGET-PREVIEW-005: arrows walk the list and stop at both ends', async () => {
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
render(<ReceiptPreviewModal receipts={two} onClose={vi.fn()} />)
await waitFor(() => expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0))
fireEvent.keyDown(window, { key: 'ArrowLeft' })
expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0)
fireEvent.keyDown(window, { key: 'ArrowRight' })
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0)
})
it('FE-BUDGET-PREVIEW-006: an out-of-range initialIndex is clamped instead of rendering nothing', async () => {
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
render(<ReceiptPreviewModal receipts={two} initialIndex={99} onClose={vi.fn()} />)
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
})
it('FE-BUDGET-PREVIEW-007: Escape closes the viewer and is stopped before the form behind it sees it', async () => {
const onClose = vi.fn()
const parent = vi.fn()
document.addEventListener('keydown', parent)
try {
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={onClose} />)
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
fireEvent.keyDown(window, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
// The expense form registers its own Escape handler on `document`; one
// keypress must not close both and throw away the user's edits.
expect(parent).not.toHaveBeenCalled()
} finally {
document.removeEventListener('keydown', parent)
}
})
it('FE-BUDGET-PREVIEW-008: an empty list renders nothing at all', () => {
render(<ReceiptPreviewModal receipts={[]} onClose={vi.fn()} />)
expect(document.body.querySelector('object')).toBeNull()
expect(screen.queryByRole('img')).toBeNull()
})
})
@@ -0,0 +1,405 @@
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { ExternalLink, Download, X, FileText, ChevronLeft, ChevronRight, FileImage } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getAuthUrl } from '../../api/authUrl'
import { openFile as openFileUrl } from '../../utils/fileDownload'
import { triggerDownload, isImage } from '../Files/FileManager.helpers'
import type { BudgetItemReceipt } from '../../types'
interface ReceiptPreviewModalProps {
receipts: BudgetItemReceipt[]
initialIndex?: number
onClose: () => void
}
export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: ReceiptPreviewModalProps) {
const { t } = useTranslation()
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, receipts.length - 1)))
const [signedUrl, setSignedUrl] = useState('')
const [loading, setLoading] = useState(false)
const current = receipts[index]
const isImg = isImage(current?.mime_type)
const isPdf = current?.mime_type === 'application/pdf' || current?.original_name?.toLowerCase().endsWith('.pdf')
useEffect(() => {
if (!current) return
let cancelled = false
setLoading(true)
setSignedUrl('')
getAuthUrl(current.url, 'download')
.then(url => {
if (!cancelled) {
setSignedUrl(url)
setLoading(false)
}
})
.catch(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [current?.url])
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// The preview opens on top of the expense form, and both the desktop
// Modal and the phone MSheet close themselves on Escape from a listener
// on `document`. Without this the one keypress closed the form too and
// threw away whatever the user had typed into it.
e.preventDefault()
e.stopPropagation()
onClose()
return
}
if (e.key === 'ArrowLeft' && index > 0) setIndex(i => i - 1)
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
}
// Capture phase, so this runs before the listeners the parents put on
// `document`; stopPropagation on the bubble phase would be too late.
window.addEventListener('keydown', handleKey, true)
return () => window.removeEventListener('keydown', handleKey, true)
}, [index, receipts.length, onClose])
if (!current) return null
const handleOpenTab = () => {
openFileUrl(current.url, current.original_name).catch(() => {})
}
const handleDownload = () => {
triggerDownload(current.url, current.original_name)
}
return createPortal(
<div
role="presentation"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.85)',
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
onClick={e => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
role="presentation"
style={{
width: '100%',
maxWidth: 950,
height: '94vh',
background: 'var(--bg-card)',
borderRadius: 12,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}
onClick={e => e.stopPropagation()}
>
{/* Header - identical to PdfPreviewModal */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
borderBottom: '1px solid var(--border-primary)',
flexShrink: 0,
}}
>
<span
style={{
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontWeight: 600,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{current.original_name}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
type="button"
onClick={handleOpenTab}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
color: 'var(--text-muted)',
background: 'none',
border: 'none',
cursor: 'pointer',
textDecoration: 'none',
padding: '4px 8px',
borderRadius: 6,
transition: 'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
>
<ExternalLink size={13} /> {t('files.openTab')}
</button>
<button
type="button"
onClick={handleDownload}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
color: 'var(--text-muted)',
background: 'none',
border: 'none',
cursor: 'pointer',
textDecoration: 'none',
padding: '4px 8px',
borderRadius: 6,
transition: 'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
>
<Download size={13} /> {t('files.download') || 'Download'}
</button>
<button
type="button"
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-faint)',
display: 'flex',
padding: 4,
borderRadius: 6,
transition: 'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<X size={18} />
</button>
</div>
</div>
{/* Tab strip if multiple receipts */}
{receipts.length > 1 && (
<div
className="bg-surface-secondary"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
borderBottom: '1px solid var(--border-primary)',
overflowX: 'auto',
flexShrink: 0,
}}
>
{receipts.map((r, i) => {
const active = i === index
const isRImg = isImage(r.mime_type)
const Icon = isRImg ? FileImage : FileText
return (
<button
key={r.id}
type="button"
onClick={() => setIndex(i)}
className={active ? 'bg-surface-card text-content font-semibold shadow-sm' : 'text-content-muted hover:text-content'}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '5px 11px',
borderRadius: 8,
border: active ? '1px solid var(--border-primary)' : '1px solid transparent',
background: active ? 'var(--bg-card)' : 'none',
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))',
cursor: 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
fontFamily: 'inherit',
transition: 'all 0.15s',
}}
>
<Icon size={12} className={active ? 'text-content' : 'text-content-faint'} />
<span style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{r.original_name}
</span>
<span
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
opacity: active ? 0.8 : 0.5,
marginLeft: 2,
}}
>
#{i + 1}
</span>
</button>
)
})}
</div>
)}
{/* Content Body */}
<div
style={{
flex: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: isImg ? '#0f141c' : 'var(--bg-card)',
minHeight: 0,
}}
>
{receipts.length > 1 && index > 0 && (
<button
type="button"
onClick={() => setIndex(i => i - 1)}
style={{
position: 'absolute',
left: 14,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10,
width: 36,
height: 36,
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
color: '#fff',
border: '1px solid rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<ChevronLeft size={20} />
</button>
)}
{receipts.length > 1 && index < receipts.length - 1 && (
<button
type="button"
onClick={() => setIndex(i => i + 1)}
style={{
position: 'absolute',
right: 14,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10,
width: 36,
height: 36,
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
color: '#fff',
border: '1px solid rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<ChevronRight size={20} />
</button>
)}
{isPdf ? (
<object
data={signedUrl ? `${signedUrl}#view=FitH` : undefined}
type="application/pdf"
style={{ flex: 1, width: '100%', height: '100%', border: 'none' }}
title={current.original_name}
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button
type="button"
onClick={handleOpenTab}
style={{
color: 'var(--text-primary)',
textDecoration: 'underline',
background: 'none',
border: 'none',
cursor: 'pointer',
font: 'inherit',
}}
>
{t('files.downloadPdf')}
</button>
</p>
</object>
) : isImg ? (
<div
style={{
padding: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
}}
>
{signedUrl ? (
<img
src={signedUrl}
alt={current.original_name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: 6,
display: 'block',
}}
/>
) : (
<div className="text-content-faint" style={{ padding: 48 }}>
{loading ? `${t('common.loading')}...` : current.original_name}
</div>
)}
</div>
) : (
<div style={{ padding: 40, textAlign: 'center' }}>
<FileText size={44} className="text-content-muted mx-auto" style={{ marginBottom: 16 }} />
<p className="text-content font-medium" style={{ marginBottom: 14 }}>
{current.original_name}
</p>
<button
type="button"
onClick={handleDownload}
className="bg-accent text-accent-text"
style={{
padding: '9px 18px',
borderRadius: 10,
border: 0,
cursor: 'pointer',
fontWeight: 600,
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
}}
>
{t('files.download')}
</button>
</div>
)}
</div>
</div>
</div>,
document.body
)
}
@@ -0,0 +1,91 @@
// FE-BUDGET-RECEIPT-001 to FE-BUDGET-RECEIPT-008: uploading receipts with an
// expense, and taking the uploads back out when the expense fails to save.
import { vi } from 'vitest'
import { uploadReceipts, discardReceipts, saveWithReceipts } from './receiptUploads'
const files = vi.hoisted(() => ({
upload: vi.fn(),
delete: vi.fn(),
permanentDelete: vi.fn(),
}))
vi.mock('../../api/client', () => ({ filesApi: files }))
function file(name: string) {
return new File(['x'], name, { type: 'image/jpeg' })
}
beforeEach(() => {
files.upload.mockReset().mockImplementation(() => Promise.resolve({ file: { id: 7 } }))
files.delete.mockReset().mockResolvedValue({})
files.permanentDelete.mockReset().mockResolvedValue({})
})
describe('uploadReceipts', () => {
it('FE-BUDGET-RECEIPT-001: names the expense on the form data when there is one', async () => {
await uploadReceipts(3, [file('a.jpg')], 42)
const fd = files.upload.mock.calls[0][1] as FormData
expect(fd.get('budget_item_id')).toBe('42')
})
it('FE-BUDGET-RECEIPT-002: leaves it out for an expense that does not exist yet', async () => {
await uploadReceipts(3, [file('a.jpg')], null)
const fd = files.upload.mock.calls[0][1] as FormData
expect(fd.get('budget_item_id')).toBeNull()
})
it('FE-BUDGET-RECEIPT-003: returns the ids in order and skips a response without one', async () => {
files.upload
.mockResolvedValueOnce({ file: { id: 11 } })
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ file: { id: 13 } })
expect(await uploadReceipts(3, [file('a'), file('b'), file('c')], null)).toEqual([11, 13])
})
})
describe('discardReceipts', () => {
it('FE-BUDGET-RECEIPT-004: trashes before it deletes, because the permanent route only accepts a trashed file', async () => {
const order: string[] = []
files.delete.mockImplementation(() => { order.push('delete'); return Promise.resolve({}) })
files.permanentDelete.mockImplementation(() => { order.push('permanent'); return Promise.resolve({}) })
expect(await discardReceipts(3, [9])).toEqual([])
expect(order).toEqual(['delete', 'permanent'])
})
it('FE-BUDGET-RECEIPT-005: reports the ids it could not remove instead of swallowing them', async () => {
files.delete.mockRejectedValueOnce(new Error('403'))
expect(await discardReceipts(3, [9, 10])).toEqual([9])
})
})
describe('saveWithReceipts', () => {
it('FE-BUDGET-RECEIPT-006: hands the uploaded ids to the save and returns its result', async () => {
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
const save = vi.fn().mockResolvedValue('saved')
const out = await saveWithReceipts(3, [file('a'), file('b')], null, save)
expect(save).toHaveBeenCalledWith([5, 6])
expect(out.result).toBe('saved')
expect(files.delete).not.toHaveBeenCalled()
})
it('FE-BUDGET-RECEIPT-007: a failing save takes every upload back out again', async () => {
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
const save = vi.fn().mockRejectedValue(new Error('nope'))
await expect(saveWithReceipts(3, [file('a'), file('b')], null, save)).rejects.toThrow('nope')
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5, 6])
expect(files.permanentDelete.mock.calls.map(c => c[1])).toEqual([5, 6])
})
it('FE-BUDGET-RECEIPT-008: a failure partway through the upload rolls back what already went up, and says what stuck', async () => {
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockRejectedValueOnce(new Error('offline'))
files.delete.mockRejectedValueOnce(new Error('403'))
const save = vi.fn()
const err = await saveWithReceipts(3, [file('a'), file('b')], null, save).catch(e => e)
expect(save).not.toHaveBeenCalled()
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5])
expect(err.stuckReceiptIds).toEqual([5])
})
})
@@ -0,0 +1,87 @@
import { filesApi } from '../../api/client'
/**
* Uploading receipts alongside an expense, and undoing it when the expense
* itself fails to save.
*
* One module for both shells. The desktop form and the phone sheet each had
* their own copy of this, three near-identical try/catch blocks apiece, which is
* six places for the same bug to sit and six copies against the duplication
* budget the PR gate measures.
*/
/** Upload each file and return the ids the server handed back, in order. */
export async function uploadReceipts(
tripId: number | string,
files: readonly File[],
budgetItemId: number | null,
): Promise<number[]> {
const ids: number[] = []
for (const file of files) {
const fd = new FormData()
fd.append('file', file)
// Only an existing expense can be named here. A new one has no id yet, so
// its receipts are linked by the create call instead.
if (budgetItemId != null) fd.append('budget_item_id', String(budgetItemId))
const res = await filesApi.upload(tripId, fd)
if (res?.file?.id) ids.push(res.file.id)
}
return ids
}
/**
* Undo an upload after the save it belonged to failed.
*
* Two calls per file, and the order matters: the permanent route only accepts a
* file that is already in the trash (`deleted_at IS NOT NULL`), so calling it on
* a freshly uploaded file answers 404 and the file stays on the trip. The soft
* delete puts it there first. It also drops the receipt link the upload created,
* so a failed edit does not leave the file attached to the expense.
*
* Returns the ids it could not remove. The caller is expected to say so rather
* than swallow it: a file left behind is a file the user has to find in the
* Files tab and delete by hand.
*/
export async function discardReceipts(
tripId: number | string,
ids: readonly number[],
): Promise<number[]> {
const stuck: number[] = []
for (const id of ids) {
try {
await filesApi.delete(tripId, id)
await filesApi.permanentDelete(tripId, id)
} catch {
stuck.push(id)
}
}
return stuck
}
/**
* Upload the pending receipts, run the save, and take the uploads back out
* again if it throws.
*
* The rollback runs for a failure in the save AND for one partway through the
* upload loop, which is why the ids are collected outside the try.
*/
export async function saveWithReceipts<T>(
tripId: number | string,
files: readonly File[],
budgetItemId: number | null,
save: (uploadedIds: number[]) => Promise<T>,
): Promise<{ result: T; stuckIds: number[] }> {
const uploaded: number[] = []
try {
for (const file of files) {
const [id] = await uploadReceipts(tripId, [file], budgetItemId)
if (id != null) uploaded.push(id)
}
const result = await save(uploaded)
return { result, stuckIds: [] }
} catch (err) {
const stuck = await discardReceipts(tripId, uploaded)
;(err as { stuckReceiptIds?: number[] }).stuckReceiptIds = stuck
throw err
}
}
+11 -6
View File
@@ -1,5 +1,5 @@
import { createPortal } from 'react-dom'
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
import { ArrowUp, ImagePlus, Reply, Smile, X } from 'lucide-react'
import type { User } from '../../types'
import { useCollabChat } from './useCollabChat'
import { ChatMessages } from './CollabChatMessages'
@@ -14,7 +14,7 @@ interface CollabChatProps {
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const S = useCollabChat(tripId, currentUser)
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
const { t, canEdit, loading, text, replyTo, setReplyTo, sending, showEmoji, setShowEmoji, reactMenu, setReactMenu, containerRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, handleTextChange, handleSend, handleKeyDown, handleReact, handleEmojiSelect } = S
if (loading) {
return (
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
@@ -27,7 +27,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
<ChatMessages {...S} />
{/* Composer */}
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
<div onDragOver={e => e.preventDefault()} onDrop={handleDrop} style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
{/* Reply preview */}
{replyTo && (
<div style={{
@@ -48,6 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</div>
)}
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label="Remove image" style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Uploading {uploadProgress}%</div>}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
@@ -61,6 +63,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</button>
)}
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label="Attach images" style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
<textarea
ref={textareaRef}
rows={1}
@@ -76,15 +80,16 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
/>
{/* Send */}
{canEdit && (
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
<button type="button" onClick={handleSend} disabled={(!text.trim() && !imageFiles.length) || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
background: text.trim() || imageFiles.length ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
cursor: text.trim() || imageFiles.length ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
@@ -0,0 +1,10 @@
import { useState } from 'react'
import { X } from 'lucide-react'
export function CollabChatAttachment({ attachment }: { attachment: { url: string; original_name?: string; mime_type?: string } }) {
const [open, setOpen] = useState(false)
return <>
<img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label="Close image" onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
</>
}
@@ -3,6 +3,7 @@ import { Trash2, Reply, ChevronUp } from 'lucide-react'
import { URL_REGEX } from './CollabChat.constants'
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
import { MessageText } from './CollabChatMessageText'
import { CollabChatAttachment } from './CollabChatAttachment'
import { LinkPreview } from './CollabChatLinkPreview'
import { ReactionBadge } from './CollabChatReactionBadge'
import EmptyState from '../shared/EmptyState'
@@ -166,6 +167,7 @@ export function ChatMessages(props: any) {
{hasReply ? (
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
) : <MessageText text={msg.text} />}
{msg.attachments?.map((attachment: any) => <CollabChatAttachment key={attachment.id} attachment={attachment} />)}
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
))}
@@ -0,0 +1,123 @@
import { FormEvent, useEffect, useState } from 'react'
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
interface CollabLink {
id: number
title: string
url: string
pinned: boolean | number
}
function LinkIcon({ url, title }: { url: string; title: string }) {
const [failed, setFailed] = useState(false)
let favicon = ''
try { favicon = new URL('/favicon.ico', url).href } catch { favicon = '' }
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
}
export default function CollabLinks({ tripId }: { tripId: number }) {
const { t } = useTranslation()
const trip = useTripStore(s => s.trip)
const canEdit = useCanDo()('collab_edit', trip)
const toast = useToast()
const [links, setLinks] = useState<CollabLink[]>([])
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
let cancelled = false
collabApi.getLinks(tripId)
.then(d => { if (!cancelled) setLinks(d.links || []) })
.catch(err => {
if (cancelled) return
console.error('Failed to load collab links:', err)
toast.error(t('common.error'))
})
return () => { cancelled = true }
}, [tripId, toast, t])
// Live sync, the same shape the notes panel uses. Without it the three
// collab:link events the server broadcasts would arrive nowhere and a link
// somebody else added would only appear on the next reload.
useEffect(() => {
if (!tripId) return
const handler = (msg: { type?: string; tripId?: number | string; link?: CollabLink; linkId?: number }) => {
// The panel is not remounted on a trip change, so an event still in
// flight from the trip we just left must not land in this list.
if (String(msg?.tripId) !== String(tripId)) return
if (msg.type === 'collab:link:created' && msg.link) {
setLinks(prev => prev.some(l => l.id === msg.link!.id) ? prev : [msg.link!, ...prev])
}
if (msg.type === 'collab:link:updated' && msg.link) {
setLinks(prev => prev
.map(l => l.id === msg.link!.id ? { ...l, ...msg.link! } : l)
.sort((a, b) => Number(b.pinned) - Number(a.pinned)))
}
if (msg.type === 'collab:link:deleted' && msg.linkId) {
setLinks(prev => prev.filter(l => l.id !== msg.linkId))
}
}
addListener(handler)
return () => removeListener(handler)
}, [tripId])
// Every write says something when it fails. Silence left the pin looking
// unchanged and the delete looking ignored, with an unhandled rejection in
// the console as the only trace.
const add = async (event?: FormEvent) => {
event?.preventDefault()
if (!title.trim() || !url.trim() || busy) return
setBusy(true)
try {
const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() })
setLinks(v => [d.link, ...v])
setTitle(''); setUrl(''); setExpanded(false)
} catch (err) {
console.error('Failed to add collab link:', err)
toast.error(t('common.error'))
} finally {
setBusy(false)
}
}
const toggle = async (link: CollabLink) => {
try {
const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned })
setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned)))
} catch (err) {
console.error('Failed to pin collab link:', err)
toast.error(t('common.error'))
}
}
const remove = async (id: number) => {
try {
await collabApi.deleteLink(tripId, id)
setLinks(v => v.filter(x => x.id !== id))
} catch (err) {
console.error('Failed to delete collab link:', err)
toast.error(t('common.error'))
}
}
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px', borderBottom: '1px solid var(--border-faint)' }}><Link2 size={18} aria-hidden="true" /><strong>{t('collab.tabs.links')}</strong><span style={{ marginLeft: 'auto', color: 'var(--text-faint)', fontSize: 12 }}>{links.length}</span></div>
{canEdit && <div style={{ padding: 10, borderBottom: '1px solid var(--border-faint)' }}>
<button type="button" onClick={() => setExpanded(v => !v)} aria-expanded={expanded} aria-controls={`collab-link-form-${tripId}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 0, borderRadius: 8, padding: '7px 10px', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontWeight: 600 }}>{expanded ? <X size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}{expanded ? t('collab.links.cancel') : t('collab.links.add')}</button>
{expanded && <form id={`collab-link-form-${tripId}`} onSubmit={add} style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
<label htmlFor={`collab-link-title-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.titlePlaceholder')}</label>
<input id={`collab-link-title-${tripId}`} value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={{ padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
<div style={{ display: 'flex', gap: 6 }}><label htmlFor={`collab-link-url-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.urlPlaceholder')}</label><input id={`collab-link-url-${tripId}`} type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} /><button type="submit" disabled={busy || !title.trim() || !url.trim()} aria-label={t('collab.links.save')} style={{ width: 36, border: 0, borderRadius: 8, background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer' }}><Plus size={18} aria-hidden="true" /></button></div>
</form>}
</div>}
<div style={{ flex: 1, overflowY: 'auto', padding: 10 }}>{links.length === 0 ? <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-faint)' }}>{t('collab.links.empty')}</div> : links.map(link => <div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}><LinkIcon url={link.url} title={link.title} /><a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}><div style={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div><div style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div></a><ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />{canEdit && <><button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}><Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /></button><button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}><Trash2 size={15} aria-hidden="true" /></button></>}</div>)}</div>
</div>
}
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore'
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
vi.mock('./CollabLinks', () => ({ default: () => <div data-testid="collab-links">Links</div> }))
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
vi.mock('../../api/websocket', () => ({
@@ -39,11 +40,12 @@ describe('CollabPanel', () => {
})
// FE-COMP-COLLABPANEL-001
it('desktop layout renders all four panels', () => {
it('desktop layout renders all five panels', () => {
setViewport(1280)
render(<CollabPanel tripId={1} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
})
@@ -55,6 +57,7 @@ describe('CollabPanel', () => {
// Tab buttons exist
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^links$/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
// Only chat visible by default
@@ -73,6 +76,13 @@ describe('CollabPanel', () => {
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
it('mobile: Links tab shows the translated label, not the i18n key', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
expect(screen.getByRole('button', { name: 'Links' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collab.tabs.links' })).not.toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-004
it('mobile: clicking Polls tab switches to CollabPolls', () => {
setViewport(375)
@@ -147,7 +157,7 @@ describe('CollabPanel', () => {
// FE-W5CPN-001 to FE-W5CPN-013
// The desktop branch picks a different layout for every combination of enabled
// collab features, so each combination gets its own case.
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
const allOff = { chat: false, notes: false, links: false, polls: false, whatsnext: false }
describe('CollabPanel feature combinations', () => {
beforeEach(() => {
@@ -172,6 +182,7 @@ describe('CollabPanel feature combinations', () => {
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true }} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
})
@@ -253,6 +264,42 @@ describe('CollabPanel feature combinations', () => {
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
// The three-or-more branch used to render notes and links as a pair and drop
// whichever of the two was on its own, so these walk every shape of it.
it('FE-W5CPN-014: three right panels including notes but not links keeps notes', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: false, polls: true, whatsnext: true }} />)
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
})
it('FE-W5CPN-015: three right panels including links but not notes keeps links', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: false, links: true, polls: true, whatsnext: true }} />)
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
it('FE-W5CPN-016: all four right panels render, notes and links above the other two', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }} />)
for (const id of ['collab-chat', 'collab-notes', 'collab-links', 'collab-polls', 'whats-next']) {
expect(screen.getByTestId(id)).toBeInTheDocument()
}
})
it('FE-W5CPN-017: notes and links alone with a third panel and no chat all render', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, links: true, polls: true }} />)
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
})
it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => {
setViewport(375)
const { rerender } = render(<CollabPanel tripId={1} />)
+45 -54
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useMemo } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
import { MessageCircle, StickyNote, Link2, BarChart3, Sparkles } from 'lucide-react'
import CollabChat from './CollabChat'
import CollabNotes from './CollabNotes'
import CollabPolls from './CollabPolls'
import WhatsNextWidget from './WhatsNextWidget'
import CollabLinks from './CollabLinks'
function useIsDesktop(breakpoint = 1024) {
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
@@ -28,6 +29,7 @@ interface TripMember {
interface CollabFeatures {
chat: boolean
notes: boolean
links?: boolean
polls: boolean
whatsnext: boolean
}
@@ -39,10 +41,11 @@ interface CollabPanelProps {
}
const ALL_TABS = [
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
{ id: 'links', featureKey: 'links' as const, labelKey: 'collab.tabs.links', fallback: 'Links', icon: Link2 },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
]
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
@@ -50,12 +53,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
const { t } = useTranslation()
const isDesktop = useIsDesktop()
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
// Older server/admin configs predate the Links feature; merge defaults so a
// missing `links` key does not silently hide the new panel.
const features = { chat: true, notes: true, links: true, polls: true, whatsnext: true, ...(collabFeatures || {}) }
const tabs = useMemo(() =>
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
...tab,
label: t(tab.labelKey),
label: t(tab.labelKey) || tab.fallback,
})),
[features, t])
@@ -70,11 +75,28 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
const chatOn = features.chat
const rightPanels = [
features.notes && 'notes',
features.notes && 'notes', features.links && 'links',
features.polls && 'polls',
features.whatsnext && 'whatsnext',
].filter(Boolean) as string[]
// One place decides what a panel id renders. It was spelled out at five call
// sites, and the layout branch below dropped a panel precisely because one of
// those copies did not list it.
const renderPanel = (p: string) => (
<>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'links' && <CollabLinks tripId={tripId} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</>
)
const panelRow = (ids: string[]) => (
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
{ids.map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>{renderPanel(p)}</div>)}
</div>
)
if (tabs.length === 0) return null
if (isDesktop) {
@@ -99,35 +121,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
<CollabChat tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{rightPanels.length === 1 && (
<div className={cardClass} style={{ flex: 1 }}>
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
)}
{rightPanels.length === 2 && rightPanels.map(p => (
<div key={p} className={cardClass} style={{ flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
))}
{rightPanels.length === 3 && (
<>
<div className={cardClass} style={{ flex: 1 }}>
<CollabNotes tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
<div className={cardClass} style={{ flex: 1 }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
<div className={cardClass} style={{ flex: 1 }}>
<WhatsNextWidget tripMembers={tripMembers} />
</div>
</div>
{rightPanels.length <= 2 && panelRow(rightPanels)}
{rightPanels.length >= 3 && (() => {
// Two rows, split by kind rather than by a pair being present:
// the old condition needed notes AND links together, so turning
// the new Links feature off dropped Notes out of the layout
// entirely. Each row renders only when it has something in it.
const written = rightPanels.filter(p => p === 'notes' || p === 'links')
const rest = rightPanels.filter(p => p !== 'notes' && p !== 'links')
return <>
{written.length > 0 && panelRow(written)}
{rest.length > 0 && panelRow(rest)}
</>
)}
})()}
</div>
</div>
)
@@ -135,26 +141,10 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
// Chat off — remaining panels share full width
const panels = rightPanels
if (panels.length === 1) {
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div className={cardClass} style={{ flex: 1 }}>
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
)
}
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
{panels.map(p => (
<div key={p} className={cardClass} style={{ flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
<div key={p} className={cardClass} style={{ flex: 1 }}>{renderPanel(p)}</div>
))}
</div>
)
@@ -170,7 +160,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{tabs.map(tab => {
const active = mobileTab === tab.id
return (
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
background: active ? 'var(--accent)' : 'transparent',
@@ -187,6 +177,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
{mobileTab === 'links' && features.links && <CollabLinks tripId={tripId} />}
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* The images pinned to a chat message before it is sent.
*
* One hook for both shells. The desktop hook and the phone screen each carried
* their own copy of the same picking, previewing and clearing, which is two
* places for the same leak and a duplication bill on every line.
*
* Object URLs are held in a ref and revoked when the file they belong to goes,
* when the list is cleared and on unmount. Rebuilding every preview on each
* change, which is what both copies did, leaked one URL per image per keystroke
* of picking, and did it from inside a setState updater, so React ran it twice
* in development and leaked twice as fast.
*/
export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
export const MAX_CHAT_IMAGES = 4
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024
export interface ChatImages {
files: File[]
previews: string[]
/** Returns false when something was dropped, so the caller can say why. */
add: (incoming: FileList | File[]) => boolean
remove: (index: number) => void
clear: () => void
}
export function useChatImages(): ChatImages {
const [files, setFiles] = useState<File[]>([])
const [previews, setPreviews] = useState<string[]>([])
const urls = useRef<string[]>([])
const revokeAll = useCallback(() => {
urls.current.forEach(URL.revokeObjectURL)
urls.current = []
}, [])
useEffect(() => revokeAll, [revokeAll])
const add = useCallback((incoming: FileList | File[]) => {
const all = Array.from(incoming)
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
if (valid.length) {
setFiles(prev => {
const room = MAX_CHAT_IMAGES - prev.length
const added = valid.slice(0, Math.max(0, room))
// Only the new files get a URL; the ones already in the list keep theirs.
const fresh = added.map(URL.createObjectURL)
urls.current = [...urls.current, ...fresh]
setPreviews(p => [...p, ...fresh])
return [...prev, ...added]
})
}
return valid.length === all.length
}, [])
const remove = useCallback((index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index))
setPreviews(prev => {
const gone = prev[index]
if (gone) {
URL.revokeObjectURL(gone)
urls.current = urls.current.filter(u => u !== gone)
}
return prev.filter((_, i) => i !== index)
})
}, [])
const clear = useCallback(() => {
revokeAll()
setFiles([])
setPreviews([])
}, [revokeAll])
return { files, previews, add, remove, clear }
}
+25 -9
View File
@@ -1,10 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useState, useEffect, useRef, useCallback, type ClipboardEvent, type DragEvent } from 'react'
import { collabApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useChatImages } from './useChatImages'
import { useToast } from '../shared/Toast'
export function useCollabChat(tripId: any, currentUser: any) {
@@ -23,6 +24,8 @@ export function useCollabChat(tripId: any, currentUser: any) {
const [replyTo, setReplyTo] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [sending, setSending] = useState(false)
const images = useChatImages()
const [uploadProgress, setUploadProgress] = useState(0)
const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set())
@@ -38,6 +41,7 @@ export function useCollabChat(tripId: any, currentUser: any) {
const scrollRef = useRef(null)
const textareaRef = useRef(null)
const emojiBtnRef = useRef(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const isAtBottom = useRef(true)
const scrollToBottom = useCallback((behavior = 'auto') => {
@@ -117,23 +121,35 @@ export function useCollabChat(tripId: any, currentUser: any) {
}, [])
/* ── send ── */
const addImageFiles = useCallback((incoming: File[] | FileList) => {
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
}, [images, toast, t])
const removeImage = images.remove
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); addImageFiles(e.dataTransfer.files) }, [addImageFiles])
const handleSend = useCallback(async () => {
const body = text.trim()
if (!body || sending) return
if ((!body && !images.files.length) || sending) return
setSending(true)
try {
const payload: { text: string; reply_to?: number } = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const data = await collabApi.sendMessage(tripId, payload)
let data
if (images.files.length) {
const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); images.files.forEach(file => form.append('images', file))
data = await collabApi.sendMessage(tripId, form, { onUploadProgress: e => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) })
} else {
const payload: { text: string; reply_to?: number } = { text: body }; if (replyTo) payload.reply_to = replyTo.id
data = await collabApi.sendMessage(tripId, payload)
}
if (data?.message) {
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
}
setText(''); setReplyTo(null); setShowEmoji(false)
setText(''); setReplyTo(null); setShowEmoji(false); images.clear()
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
} catch { toast.error(t('common.error')) } finally { setSending(false) }
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
} catch { toast.error(t('common.error')) } finally { setSending(false); setUploadProgress(0) }
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
@@ -175,5 +191,5 @@ export function useCollabChat(tripId: any, currentUser: any) {
return emojiRegex.test(text.trim())
}
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles: images.files, imagePreviews: images.previews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
}
@@ -5,6 +5,7 @@ import { collectionsApi } from '../../api/collections'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import EmptyState from '../shared/EmptyState'
interface LocationBias {
low: { lat: number; lng: number }
@@ -53,7 +54,16 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
return merged
}
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
/**
* Compact click-away dropdown (Tailwind, this panel lives outside .trek-dash).
*
* The panel is positioned against the FILTER ROW rather than against this
* button, so it opens across the full width of the row instead of the half its
* own trigger occupies. The two filters sit side by side and only one can be
* open at a time, so the wider panel costs nothing and stops list names being
* truncated at roughly ten characters. The row carries the `relative` this
* needs; there is no other call site.
*/
function FilterDropdown({ current, options, onSelect, lead }: {
current: string | number
options: Opt[]
@@ -72,7 +82,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
}, [open])
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="relative min-w-0 flex-1" ref={ref}>
<div className="min-w-0 flex-1" ref={ref}>
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
@@ -80,7 +90,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
<ChevronDown size={13} className="shrink-0 text-content-faint" />
</button>
{open && (
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
{options.map(o => (
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
@@ -173,7 +183,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
]
return (
<aside className="w-full sm:w-64 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
// Same 320px as the details column on the other side of the form: two panels
// of different widths flanking one form read as a mistake rather than a
// hierarchy, and neither of them is the more important one.
<aside className="w-full sm:w-80 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
<Bookmark size={15} className="text-accent" />
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
@@ -189,7 +202,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
/>
</div>
{lists.length > 0 && (
<div className="flex gap-2">
<div className="relative flex gap-2">
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
</div>
@@ -201,7 +214,13 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
<Loader2 size={18} className="animate-spin" />
</div>
) : visible.length === 0 ? (
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
<EmptyState
scene="search"
title={t('collections.picker.empty')}
size={84}
fill
surface="var(--bg-secondary)"
/>
) : (
<div className="flex flex-col gap-1">
{page.map(place => (
@@ -0,0 +1,101 @@
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { DatePicker } from './JourneyDetailPageDatePicker'
function open(value = '2026-09-11') {
const onChange = vi.fn()
render(<DatePicker value={value} onChange={onChange} />)
return { onChange }
}
const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i })
const prev = (name: RegExp) => screen.getByRole('button', { name })
describe('DatePicker year navigation (#2318)', () => {
it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => {
const user = userEvent.setup()
open()
await user.click(screen.getByText(/Sep 11, 2026/))
expect(screen.getByText('Mo')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026')
})
it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => {
const user = userEvent.setup()
open()
await user.click(screen.getByText(/Sep 11, 2026/))
await user.click(screen.getByRole('button', { name: 'Select month' }))
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026')
await user.click(screen.getByRole('button', { name: 'Select year' }))
expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument()
expect(header()).toHaveTextContent('2016 – 2027')
})
it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => {
const user = userEvent.setup()
open()
await user.click(screen.getByText(/Sep 11, 2026/))
await user.click(screen.getByRole('button', { name: 'Select month' }))
await user.click(screen.getByRole('button', { name: 'Select year' }))
await user.click(screen.getByRole('button', { name: '2023' }))
// Back on the months, of the chosen year.
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023')
await user.click(screen.getByRole('button', { name: 'Mar' }))
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023')
expect(screen.getByText('Mo')).toBeInTheDocument()
})
it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => {
const user = userEvent.setup()
open()
await user.click(screen.getByText(/Sep 11, 2026/))
await user.click(prev(/previous month/i))
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026')
await user.click(screen.getByRole('button', { name: 'Select month' }))
await user.click(prev(/previous year/i))
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025')
await user.click(screen.getByRole('button', { name: 'Select year' }))
await user.click(prev(/previous years/i))
expect(header()).toHaveTextContent('2004 – 2015')
await user.click(screen.getByRole('button', { name: /next years/i }))
expect(header()).toHaveTextContent('2016 – 2027')
})
it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => {
const user = userEvent.setup()
const { onChange } = open()
await user.click(screen.getByText(/Sep 11, 2026/))
await user.click(screen.getByRole('button', { name: 'Select month' }))
await user.click(screen.getByRole('button', { name: 'Select year' }))
await user.click(screen.getByRole('button', { name: '2023' }))
await user.click(screen.getByRole('button', { name: 'Mar' }))
await user.click(screen.getByRole('button', { name: '15' }))
expect(onChange).toHaveBeenCalledWith('2023-03-15')
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
})
it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => {
const user = userEvent.setup()
open()
const trigger = screen.getByText(/Sep 11, 2026/)
await user.click(trigger)
await user.click(screen.getByRole('button', { name: 'Select month' }))
await user.click(screen.getByRole('button', { name: 'Select year' }))
await user.click(trigger)
expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument()
await user.click(trigger)
expect(screen.getByText('Mo')).toBeInTheDocument()
})
})
@@ -3,6 +3,16 @@ import { localIsoDate } from '../../utils/localDate'
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
import { useTranslation } from '../../i18n'
/**
* The header steps up a level on each click, days → months → years, and a
* pick steps back down. Mirrors CustomDateTimePicker, so a person who has
* learnt one calendar in TREK knows the other. Without it the only way to a
* different year was one month at a time, which on a photo picker asked to
* cover a trip three years back meant thirty-six clicks (#2318).
*/
type CalendarView = 'days' | 'months' | 'years'
const YEAR_PAGE_SIZE = 12
export function DatePicker({ value, onChange, tripDates }: {
value: string
onChange: (date: string) => void
@@ -10,6 +20,8 @@ export function DatePicker({ value, onChange, tripDates }: {
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [view, setView] = useState<CalendarView>('days')
const [yearPageStart, setYearPageStart] = useState(0)
const [viewMonth, setViewMonth] = useState(() => {
const d = value ? new Date(value + 'T00:00:00') : new Date()
return { year: d.getFullYear(), month: d.getMonth() }
@@ -27,19 +39,73 @@ export function DatePicker({ value, onChange, tripDates }: {
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
}
const handlePrev = () => {
if (view === 'days') prevMonth()
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 }))
else setYearPageStart(s => s - YEAR_PAGE_SIZE)
}
const handleNext = () => {
if (view === 'days') nextMonth()
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 }))
else setYearPageStart(s => s + YEAR_PAGE_SIZE)
}
const handleHeaderClick = () => {
if (view === 'days') {
setView('months')
} else if (view === 'months') {
setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE)
setView('years')
}
}
const selectMonth = (month: number) => {
setViewMonth(p => ({ ...p, month }))
setView('days')
}
const selectYear = (year: number) => {
setViewMonth(p => ({ ...p, year }))
setView('months')
}
const toggleOpen = () => {
// Reopening lands on the days again: a picker left on the year grid
// would otherwise open there next time, one level away from a date.
setView('days')
setOpen(!open)
}
const prevLabel = view === 'days' ? t('common.datepicker.prevMonth')
: view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears')
const nextLabel = view === 'days' ? t('common.datepicker.nextMonth')
: view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears')
const headerLabel = view === 'days' ? monthName
: view === 'months' ? String(viewMonth.year)
: `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`
const headerAria = view === 'days' ? t('common.datepicker.selectMonth')
: view === 'months' ? t('common.datepicker.selectYear') : undefined
const pad = (n: number) => String(n).padStart(2, '0')
const cells: (number | null)[] = []
for (let i = 0; i < firstDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
const monthNames = Array.from({ length: 12 }, (_, i) =>
new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' }))
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i)
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500'
const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${
selected
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
}`
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
onClick={toggleOpen}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
>
{formatted ? (
@@ -59,54 +125,86 @@ export function DatePicker({ value, onChange, tripDates }: {
above closes the popover again from the keyboard. */}
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
{/* Month nav */}
{/* Header: arrows step the current view, the label climbs to the next one up */}
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
<ArrowLeft size={14} />
</button>
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
<button
type="button"
onClick={handleHeaderClick}
aria-label={headerAria}
disabled={view === 'years'}
className="px-2 py-1 rounded-lg text-[13px] font-semibold text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:hover:bg-transparent disabled:cursor-default"
>
{headerLabel}
</button>
<button type="button" onClick={handleNext} aria-label={nextLabel} className={navButton}>
<ChevronRight size={14} />
</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 mb-1">
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{cells.map((day, i) => {
if (day === null) return <div key={`e${i}`} />
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
const isSelected = dateStr === value
const isTrip = tripDates?.has(dateStr)
const isToday = dateStr === localIsoDate()
return (
<button
key={dateStr}
type="button"
onClick={() => { onChange(dateStr); setOpen(false) }}
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
isSelected
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: isToday
? 'text-zinc-900 dark:text-white font-bold'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
}`}
>
{day}
{isTrip && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
)}
{view === 'months' && (
<div className="grid grid-cols-3 gap-1">
{monthNames.map((name, i) => (
<button key={name} type="button" onClick={() => selectMonth(i)} className={gridCell(i === viewMonth.month)}>
{name}
</button>
)
})}
</div>
))}
</div>
)}
{view === 'years' && (
<div className="grid grid-cols-3 gap-1">
{years.map(y => (
<button key={y} type="button" onClick={() => selectYear(y)} className={gridCell(y === viewMonth.year)}>
{y}
</button>
))}
</div>
)}
{view === 'days' && (
<>
{/* Weekday headers */}
<div className="grid grid-cols-7 mb-1">
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{cells.map((day, i) => {
if (day === null) return <div key={`e${i}`} />
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
const isSelected = dateStr === value
const isTrip = tripDates?.has(dateStr)
const isToday = dateStr === localIsoDate()
return (
<button
key={dateStr}
type="button"
onClick={() => { onChange(dateStr); setOpen(false) }}
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
isSelected
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: isToday
? 'text-zinc-900 dark:text-white font-bold'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
}`}
>
{day}
{isTrip && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
)}
</button>
)
})}
</div>
</>
)}
</div>
</>
)}
@@ -1,4 +1,4 @@
// FE-JRN-PICKER-001 to FE-JRN-PICKER-020
// FE-JRN-PICKER-001 to FE-JRN-PICKER-021
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -324,6 +324,39 @@ describe('ProviderPicker', () => {
expect(range.to.endsWith('-09')).toBe(true)
})
it('FE-JRN-PICKER-021: a location picked after the photos loaded reorders the grid', async () => {
// contextLocation is live editor state: the place search and "use my location"
// write it while this picker stays mounted, and the key is only provider plus
// date. Sorting inside the fetch instead of in a memo leaves the grid frozen
// while the caption above it already claims nearest-first.
searchReturns([
asset('rome', { lat: 41.9, lng: 12.5, takenAt: '2026-03-15T09:00:00.000Z' }),
asset('helsinki', { lat: 60.17, lng: 24.94, takenAt: '2026-03-15T11:00:00.000Z' }),
])
const props = {
provider: 'immich',
userId: 42,
entries,
trips,
existingAssetIds: new Set<string>(),
onClose: vi.fn(),
onAdd: vi.fn(async () => {}),
} as React.ComponentProps<typeof ProviderPicker>
const { container, rerender } = render(<ProviderPicker {...props} />)
await screen.findByText('March 15, 2026')
const order = () => Array.from(container.querySelectorAll('img'))
.map(img => (img.getAttribute('src') || '').split('/assets/0/')[1]?.split('/')[0])
.filter(Boolean)
// Nothing to be near yet, so newest first.
expect(order()).toEqual(['helsinki', 'rome'])
rerender(<ProviderPicker {...props} contextLocation={{ lat: 41.9, lng: 12.5, name: 'Rome' }} />)
await waitFor(() => expect(order()).toEqual(['rome', 'helsinki']))
})
it('FE-JRN-PICKER-020: closes through the header button and the backdrop', async () => {
const user = userEvent.setup()
const { container, onClose } = mountPicker()
@@ -1,189 +1,244 @@
import { useEffect, useState, useRef, useMemo } from 'react'
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { memoriesApi } from '../../api/client'
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import { DatePicker } from './JourneyDetailPageDatePicker'
import { Calendar, Camera, Check, ChevronRight, Play, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { memoriesApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import {
groupPhotosByDate,
sortProviderPhotos,
type GeoPoint,
} from '../../pages/journeyDetail/JourneyDetailPage.helpers';
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore';
import { DatePicker } from './JourneyDetailPageDatePicker';
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger';
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] }
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] };
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd, initialDate, contextLocation, initialEntryId, embedded = false }: {
provider: string
userId: number
entries: JourneyEntry[]
trips: JourneyTrip[]
existingAssetIds: Set<string>
onClose: () => void
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>
initialDate?: string
contextLocation?: (GeoPoint & { name?: string }) | null
initialEntryId?: number | null
embedded?: boolean
export function ProviderPicker({
provider,
userId,
entries,
trips,
existingAssetIds,
onClose,
onAdd,
initialDate,
contextLocation,
initialEntryId,
embedded = false,
}: {
provider: string;
userId: number;
entries: JourneyEntry[];
trips: JourneyTrip[];
existingAssetIds: Set<string>;
onClose: () => void;
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>;
initialDate?: string;
contextLocation?: (GeoPoint & { name?: string }) | null;
initialEntryId?: number | null;
embedded?: boolean;
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip')
const [photos, setPhotos] = useState<any[]>([])
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [searchPage, setSearchPage] = useState(1)
const [searchFrom, setSearchFrom] = useState('')
const [searchTo, setSearchTo] = useState('')
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null)
const [addToOpen, setAddToOpen] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const gridRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation();
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip');
const [photos, setPhotos] = useState<any[]>([]);
const [albums, setAlbums] = useState<
Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
>([]);
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null);
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [searchPage, setSearchPage] = useState(1);
const [searchFrom, setSearchFrom] = useState('');
const [searchTo, setSearchTo] = useState('');
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(
new Map()
);
const [customFrom, setCustomFrom] = useState('');
const [customTo, setCustomTo] = useState('');
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null);
const [addToOpen, setAddToOpen] = useState(false);
const [adding, setAdding] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
// compute trip range
const tripRange = useMemo(() => {
let from = '', to = ''
let from = '',
to = '';
for (const t of trips) {
if (t.start_date && (!from || t.start_date < from)) from = t.start_date
if (t.end_date && (!to || t.end_date > to)) to = t.end_date
if (t.start_date && (!from || t.start_date < from)) from = t.start_date;
if (t.end_date && (!to || t.end_date > to)) to = t.end_date;
}
return { from, to }
}, [trips])
return { from, to };
}, [trips]);
const cancelPending = () => {
if (abortRef.current) { abortRef.current.abort() }
abortRef.current = new AbortController()
return abortRef.current.signal
}
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
return abortRef.current.signal;
};
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
const signal = cancelPending()
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
setSearchFrom(from)
setSearchTo(to)
setSearchPage(page)
const signal = cancelPending();
if (page === 1) {
setLoading(true);
setPhotos([]);
} else {
setLoadingMore(true);
}
setSearchFrom(from);
setSearchTo(to);
setSearchPage(page);
try {
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
const assets = data.assets || []
setPhotos(prev => append ? [...prev, ...assets] : assets)
setHasMore(!!data.hasMore)
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal);
const assets = data.assets || [];
setPhotos((prev) => (append ? [...prev, ...assets] : assets));
setHasMore(!!data.hasMore);
} catch {
// A cancelled request is about to be replaced by the next one, so leave
// its paging state alone.
if (!signal.aborted) setHasMore(false)
if (!signal.aborted) setHasMore(false);
}
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
}
if (!signal.aborted) {
setLoading(false);
setLoadingMore(false);
}
};
const loadMorePhotos = () => {
if (loadingMore || !hasMore) return
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
}
if (loadingMore || !hasMore) return;
searchPhotos(searchFrom, searchTo, searchPage + 1, true);
};
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
const signal = cancelPending()
setLoading(true)
setPhotos([])
setHasMore(false)
const signal = cancelPending();
setLoading(true);
setPhotos([]);
setHasMore(false);
try {
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
} catch { /* ignore */ }
if (!signal.aborted) setLoading(false)
}
const assets = (await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [];
setPhotos(assets);
} catch {
/* ignore */
}
if (!signal.aborted) setLoading(false);
};
const loadAlbums = async () => {
try {
setAlbums((await memoriesApi.albums(provider)).albums || [])
setAlbums((await memoriesApi.albums(provider)).albums || []);
} catch {}
}
};
// load on mount / filter change
useEffect(() => {
if (filter === 'day' && initialDate) {
searchPhotos(initialDate, initialDate)
searchPhotos(initialDate, initialDate);
} else if (filter === 'trip' && tripRange.from && tripRange.to) {
searchPhotos(tripRange.from, tripRange.to)
searchPhotos(tripRange.from, tripRange.to);
} else if (filter === 'all') {
searchPhotos('', '')
searchPhotos('', '');
} else if (filter === 'album' && albums.length === 0) {
loadAlbums()
loadAlbums();
}
}, [filter])
}, [filter]);
const handleCustomSearch = () => {
if (customFrom && customTo) searchPhotos(customFrom, customTo)
}
if (customFrom && customTo) searchPhotos(customFrom, customTo);
};
// Sorted here rather than in the two fetches above. contextLocation is live
// editor state: the place search and "use my location" write it while this
// picker stays mounted, and its key is only provider plus date, so a fetch-time
// sort would leave the grid frozen while the caption already says "near Rome".
// It also keeps the ranking global instead of per page, which a sort inside
// the appending search cannot do.
const sortedPhotos = useMemo(
() => sortProviderPhotos(photos, contextLocation),
[photos, contextLocation?.lat, contextLocation?.lng],
)
);
const toggleAsset = (id: string) => {
setSelected(prev => {
const next = new Map(prev)
setSelected((prev) => {
const next = new Map(prev);
if (next.has(id)) {
next.delete(id)
next.delete(id);
} else {
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
const mediaType = (photos as any[]).find((p) => p.id === id)?.mediaType;
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType });
}
return next
})
}
return next;
});
};
const targetLabel = targetEntryId
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
: t('journey.picker.newGallery')
? entries.find((e) => e.id === targetEntryId)?.title ||
entries.find((e) => e.id === targetEntryId)?.entry_date ||
t('journey.stats.entries')
: t('journey.picker.newGallery');
return (
<div
data-testid={embedded ? 'journey-provider-picker-embedded' : undefined}
className={embedded
? 'w-full h-full min-h-0 flex flex-col overflow-hidden'
: 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'}
className={
embedded
? 'flex h-full min-h-0 w-full flex-col overflow-hidden'
: 'fixed inset-0 z-[9999] flex items-end justify-center overscroll-none bg-[rgba(9,9,11,0.75)] md:items-center md:p-5'
}
role="presentation"
onClick={embedded ? undefined : onClose}
onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }}
onTouchMove={(e) => {
if (!embedded && e.target === e.currentTarget) e.preventDefault();
}}
>
<div
className={embedded
? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden'
: 'bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden'}
className={
embedded
? 'flex h-full w-full flex-col overflow-hidden bg-white dark:bg-zinc-900'
: 'flex max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] w-full max-w-[720px] flex-col overflow-hidden rounded-t-2xl bg-white shadow-[0_20px_40px_rgba(0,0,0,0.2)] md:max-h-[85vh] md:max-w-[960px] md:rounded-2xl dark:bg-zinc-900'
}
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
role="presentation"
onClick={e => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
</h2>
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>}
{!embedded && (
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
</h2>
<button
type="button"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
<X size={16} />
</button>
</div>
)}
{/* Filter bar */}
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<div className="flex-shrink-0 border-b border-zinc-200 px-6 py-3 dark:border-zinc-700">
{/* Tabs */}
<div className="flex gap-1.5 mb-3">
<div className="mb-3 flex gap-1.5">
{[
...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []),
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
{ id: 'album' as const, label: t('journey.picker.albums') },
].map(f => (
<button type="button"
].map((f) => (
<button
type="button"
key={f.id}
onClick={() => setFilter(f.id)}
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
className={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-colors ${
filter === f.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
}`}
>
@@ -192,180 +247,252 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
<span className="hidden sm:inline">{f.label}</span>
<span className="sm:hidden">{f.short}</span>
</>
) : f.label}
) : (
f.label
)}
</button>
))}
</div>
{/* Filter content — always visible row */}
{(!embedded || filter !== 'day') && <div className="min-h-[36px] flex items-center">
{filter === 'day' && initialDate && (
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
<Calendar size={13} className="text-zinc-400" />
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</span>
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
</div>
)}
{filter === 'trip' && (
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
{tripRange.from && tripRange.to ? (
<>
<Calendar size={13} className="text-zinc-400" />
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
<span className="text-zinc-400">&mdash;</span>
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
<span className="ml-1 text-zinc-400">
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
</span>
</>
) : (
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
)}
</div>
)}
{(!embedded || filter !== 'day') && (
<div className="flex min-h-[36px] items-center">
{filter === 'day' && initialDate && (
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
<Calendar size={13} className="text-zinc-400" />
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
</div>
)}
{filter === 'trip' && (
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
{tripRange.from && tripRange.to ? (
<>
<Calendar size={13} className="text-zinc-400" />
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})}
</span>
<span className="text-zinc-400">&mdash;</span>
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<span className="ml-1 text-zinc-400">
(
{Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) +
1}{' '}
days)
</span>
</>
) : (
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
)}
</div>
)}
{filter === 'custom' && (
<div className="flex items-center gap-2 flex-1">
<div className="flex-1"><DatePicker value={customFrom} onChange={setCustomFrom} /></div>
<span className="text-zinc-400 text-[12px]">&mdash;</span>
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
<button type="button" onClick={handleCustomSearch}
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
{t('journey.picker.search')}
</button>
</div>
)}
{filter === 'album' && (
<div className="flex gap-2 overflow-x-auto flex-1">
{albums.map((a: any) => (
<button type="button"
key={a.id}
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
selectedAlbum === a.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
{filter === 'custom' && (
<div className="flex flex-1 items-center gap-2">
<div className="flex-1">
<DatePicker value={customFrom} onChange={setCustomFrom} />
</div>
<span className="text-[12px] text-zinc-400">&mdash;</span>
<div className="flex-1">
<DatePicker value={customTo} onChange={setCustomTo} />
</div>
<button
type="button"
onClick={handleCustomSearch}
className="flex-shrink-0 rounded-lg bg-zinc-900 px-3 py-1.5 text-[12px] font-medium text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
>
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
{t('journey.picker.search')}
</button>
))}
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
</div>
)}
</div>}
</div>
)}
{filter === 'album' && (
<div className="flex flex-1 gap-2 overflow-x-auto">
{albums.map((a: any) => (
<button
type="button"
key={a.id}
onClick={() => {
setSelectedAlbum(a.id);
setSelectedAlbumPassphrase(a.passphrase);
loadAlbumPhotos(a);
}}
className={`flex-shrink-0 rounded-lg border px-2.5 py-1 text-[11px] font-medium whitespace-nowrap ${
selectedAlbum === a.id
? 'border-zinc-900 bg-zinc-900 text-white dark:border-white dark:bg-white dark:text-zinc-900'
: 'border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800'
}`}
>
{a.albumName || a.name || 'Album'}
{a.assetCount != null ? ` (${a.assetCount})` : ''}
</button>
))}
{albums.length === 0 && !loading && (
<span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>
)}
</div>
)}
</div>
)}
</div>
{/* Add-to entry selector */}
{!embedded && <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button type="button"
onClick={() => setAddToOpen(!addToOpen)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
</button>
{addToOpen && (
<>
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
<button type="button"
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
className={`w-full text-left px-3 py-2 text-[12px] flex items-center gap-2 ${
!targetEntryId
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
}`}
>
<Camera size={12} />
{t('journey.picker.newGallery')}
</button>
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
)}
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
<button type="button"
key={e.id}
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
targetEntryId === e.id
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
{!embedded && (
<div className="flex-shrink-0 border-b border-zinc-200 bg-zinc-50 px-6 py-2.5 dark:border-zinc-700 dark:bg-zinc-800/50">
<div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] text-zinc-500 uppercase">
{t('journey.picker.addTo')}
</span>
<button
type="button"
onClick={() => setAddToOpen(!addToOpen)}
className="flex items-center gap-1.5 rounded-lg border border-zinc-200 px-3 py-1.5 text-[12px] font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
</button>
{addToOpen && (
<>
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
<div className="absolute top-full left-12 z-10 mt-1 max-h-[240px] min-w-[200px] overflow-y-auto rounded-xl border border-zinc-200 bg-white py-1.5 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
<button
type="button"
onClick={() => {
setTargetEntryId(null);
setAddToOpen(false);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] ${
!targetEntryId
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
}`}
>
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
<Camera size={12} />
{t('journey.picker.newGallery')}
</button>
))}
</div>
</>
)}
{entries.filter(
(e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]'
).length > 0 && <div className="my-1 h-px bg-zinc-200 dark:bg-zinc-700" />}
{entries
.filter((e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]')
.map((e) => (
<button
type="button"
key={e.id}
onClick={() => {
setTargetEntryId(e.id);
setAddToOpen(false);
}}
className={`w-full truncate px-3 py-2 text-left text-[12px] ${
targetEntryId === e.id
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
}`}
>
{e.title ||
e.location_name ||
new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
})}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>}
)}
{/* Select all bar — sticky above grid */}
{!loading && sortedPhotos.length > 0 && (() => {
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id))
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
if (selectable.length === 0) return null
return (
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
<button type="button"
onClick={() => {
if (allSelected) {
setSelected(new Map())
} else {
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }])))
}
}}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
allSelected
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
: 'border-zinc-300 dark:border-zinc-600'
}`}>
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
</div>
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
</button>
</div>
)
})()}
{!loading &&
sortedPhotos.length > 0 &&
(() => {
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id));
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id));
if (selectable.length === 0) return null;
return (
<div className="flex-shrink-0 border-b border-zinc-200 bg-white px-4 py-2 dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => {
if (allSelected) {
setSelected(new Map());
} else {
setSelected(
new Map(
selectable.map((a: any) => [
a.id,
{
albumId: selectedAlbum ?? undefined,
passphrase: selectedAlbumPassphrase,
mediaType: a.mediaType,
},
])
)
);
}
}}
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-500 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
>
<div
className={`flex h-3.5 w-3.5 items-center justify-center rounded border ${
allSelected
? 'border-zinc-900 bg-zinc-900 dark:border-white dark:bg-white'
: 'border-zinc-300 dark:border-zinc-600'
}`}
>
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
</div>
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
</button>
</div>
);
})()}
{/* Photo grid */}
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">
{loading ? (
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
</div>
) : sortedPhotos.length === 0 ? (
<div className="text-center py-12">
<div className="py-12 text-center">
<p className="text-[13px] text-zinc-500">
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
{filter === 'trip' && !tripRange.from
? t('journey.trips.noTripsLinkedSettings')
: t('journey.detail.noPhotos')}
</p>
</div>
) : (
<div>
{groupPhotosByDate(sortedPhotos).map(group => (
{groupPhotosByDate(sortedPhotos).map((group) => (
<div key={group.date}>
{(!embedded || filter !== 'day') && (
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
{group.label}
</p>
)}
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
{group.assets.map((asset: any) => {
const isSelected = selected.has(asset.id)
const alreadyAdded = existingAssetIds.has(asset.id)
const isSelected = selected.has(asset.id);
const alreadyAdded = existingAssetIds.has(asset.id);
return (
<button
type="button"
@@ -374,42 +501,47 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
aria-pressed={isSelected}
aria-label={asset.city || group.label}
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
className={`relative block w-full aspect-square rounded-lg overflow-hidden ${
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
alreadyAdded
? 'opacity-40 cursor-not-allowed'
? 'cursor-not-allowed opacity-40'
: isSelected
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
: 'cursor-pointer'
}`}
>
<img
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
alt=""
className="w-full h-full object-cover"
className="h-full w-full object-cover"
loading="lazy"
onError={e => {
const img = e.currentTarget
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
if (!img.src.includes('/original')) img.src = original
onError={(e) => {
const img = e.currentTarget;
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`;
if (!img.src.includes('/original')) img.src = original;
}}
/>
{alreadyAdded && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
<Check size={12} />
</div>
)}
{isSelected && !alreadyAdded && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
<Check size={12} />
</div>
)}
{asset.mediaType === 'video' && (
<div className="pointer-events-none absolute bottom-1.5 left-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
<Play size={8} className="ml-px text-white" fill="currentColor" />
</div>
)}
{asset.city && (
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
<p className="text-[8px] text-white truncate">{asset.city}</p>
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
<p className="truncate text-[8px] text-white">{asset.city}</p>
</div>
)}
</button>
)
);
})}
</div>
</div>
@@ -421,35 +553,58 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
<div className="flex flex-shrink-0 items-center justify-between border-t border-zinc-200 bg-zinc-50 px-6 py-4 dark:border-zinc-700 dark:bg-zinc-800/50">
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-200/60 px-2.5 py-1 text-[11px] leading-none text-zinc-500 dark:bg-zinc-700/60 dark:text-zinc-400">
<span className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-zinc-900 px-1 text-[10px] leading-none font-bold text-white dark:bg-white dark:text-zinc-900">
{selected.size}
</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
</span>
<div className="flex items-center gap-2">
<button type="button" onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-200 px-3.5 py-2 text-[13px] font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
{t('common.cancel')}
</button>
<button type="button"
onClick={() => {
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] }
g.assetIds.push(assetId)
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image')
groupMap.set(passphrase, g)
<button
type="button"
onClick={async () => {
if (adding) return;
setAdding(true);
try {
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>();
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] };
g.assetIds.push(assetId);
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image');
groupMap.set(passphrase, g);
}
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({
assetIds: g.assetIds,
mediaTypes: g.mediaTypes,
passphrase,
}));
await onAdd(groups, targetEntryId);
} finally {
setAdding(false);
}
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
onAdd(groups, targetEntryId)
}}
disabled={selected.size === 0}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={selected.size === 0 || adding}
className="rounded-lg bg-zinc-900 px-3.5 py-2 text-[13px] font-medium text-white hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
>
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
{adding ? (
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-zinc-400 border-t-white dark:border-t-zinc-900" />
) : (
<>
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
</>
)}
</button>
</div>
</div>
</div>
</div>
)
);
}
@@ -1,4 +1,4 @@
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-006
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-009
import { render } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
@@ -87,4 +87,44 @@ describe('ScrollTrigger', () => {
unmount()
expect(observers[0].disconnect).toHaveBeenCalled()
})
it('FE-COMP-JSCROLLTRIG-007: a fresh callback identity alone does not rebuild the observer', () => {
// Both callers hand in a new arrow every render; rebuilding for that churn
// replays the initial callback and pulls pages nobody asked for.
const { rerender } = render(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
expect(observers).toHaveLength(1)
rerender(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
rerender(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
expect(observers).toHaveLength(1)
expect(observers[0].disconnect).not.toHaveBeenCalled()
})
it('FE-COMP-JSCROLLTRIG-008: the surviving observer calls the newest callback, not the one it was built with', () => {
const first = vi.fn()
const second = vi.fn()
const { rerender } = render(<ScrollTrigger onVisible={first} loading={false} />)
rerender(<ScrollTrigger onVisible={second} loading={false} />)
intersect(observers[0], true)
expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledTimes(1)
})
it('FE-COMP-JSCROLLTRIG-009: a settled page really re-observes the sentinel', () => {
// A browser observer only fires on a threshold crossing plus once per
// observe(). A sentinel that never leaves the 200px margin therefore only
// gets another callback because the loading flip rebuilds the observer, so
// loading must stay a dependency rather than moving into a ref as well.
const onVisible = vi.fn()
const { rerender } = render(<ScrollTrigger onVisible={onVisible} loading />)
expect(observers).toHaveLength(1)
rerender(<ScrollTrigger onVisible={onVisible} loading={false} />)
expect(observers).toHaveLength(2)
expect(observers[0].disconnect).toHaveBeenCalled()
})
})
@@ -2,13 +2,24 @@ import { useEffect, useRef } from 'react'
export function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
const ref = useRef<HTMLDivElement>(null)
// Both callers hand in a fresh arrow on every render — the picker its
// loadMorePhotos, the gallery its setVisibleCount step. With onVisible in the
// effect deps, every unrelated re-render tore the observer down and observed
// again, and each observe() replays the initial callback. The ref keeps the
// observer out of that churn.
//
// loading stays a dependency on purpose: re-observing after a page has landed
// is the only thing that still produces a callback when the sentinel never
// left the 200px margin.
const onVisibleRef = useRef(onVisible)
onVisibleRef.current = onVisible
useEffect(() => {
const el = ref.current
if (!el) return
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisibleRef.current() }, { rootMargin: '200px' })
obs.observe(el)
return () => obs.disconnect()
}, [onVisible, loading])
}, [loading])
return (
<div ref={ref} className="flex justify-center py-4 mt-2">
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
@@ -86,6 +86,9 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => ({
}),
}));
vi.mock('../Map/engines/maplibre', () => ({ default: {} }));
// jsdom refuses a WebGL context, and the basemap now believes it (#2288). These
// cases are about the GL layer, so the probe says yes here.
vi.mock('../../utils/webgl', () => ({ hasWebGL: () => true, resetWebGLProbe: () => {} }));
const entriesWithCoords = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
+5 -4
View File
@@ -4,7 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useCartoApiKey } from '../../hooks/useTileUrl'
import { isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults'
import { attachVectorBasemap, type GlLeafletLayer } from '../Map/VectorBasemap'
import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap'
import { escapeHtml, type JourneyTrack } from '@trek/shared'
export interface MapMarkerItem {
@@ -162,7 +162,8 @@ function JourneyMap(
const tileUrlRef = useRef(tileUrl)
tileUrlRef.current = tileUrl
const tileLayerRef = useRef<L.TileLayer | null>(null)
const glLayerRef = useRef<GlLeafletLayer | null>(null)
// GL layer or the raster stand-in a browser without WebGL gets instead (#2288).
const glLayerRef = useRef<BasemapLayer | null>(null)
// The vector basemap loads async; a map torn down before it lands must not get one.
const cancelledRef = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
@@ -379,7 +380,7 @@ function JourneyMap(
map.remove()
mapRef.current = null
tileLayerRef.current = null
glLayerRef.current?.remove()
detachBasemapLayer(glLayerRef.current)
glLayerRef.current = null
markersRef.current.clear()
}
@@ -389,7 +390,7 @@ function JourneyMap(
// marker and track it just drew. A vector basemap restyles instead, which also
// avoids spending a WebGL context on every theme toggle.
useEffect(() => {
if (isVectorStyle(tileUrl)) glLayerRef.current?.getMaplibreMap()?.setStyle(tileUrl)
if (isVectorStyle(tileUrl)) restyleBasemap(glLayerRef.current, tileUrl)
else tileLayerRef.current?.setUrl(tileUrl)
}, [tileUrl])
+22 -3
View File
@@ -25,14 +25,33 @@ describe('buildNavItems', () => {
})
describe('splitMobileNav', () => {
it('falls back to Dashboard + first two in the bar when there is no config', () => {
it('docks the top two enabled defaults when there is no config', () => {
for (const cfg of [undefined, { bar: [], more: [] }]) {
const s = splitMobileNav(items, cfg)
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
expect(ids(s.more)).toEqual(['journey', 'collections'])
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'journey'])
// Atlas is still reachable, one tap further under More.
expect(ids(s.more)).toEqual(['atlas', 'collections'])
}
})
it('keeps the Vacay/Atlas dock on an instance where Journey is switched off', () => {
const s = splitMobileNav([item('dashboard'), item('vacay'), item('atlas')], undefined)
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
expect(ids(s.more)).toEqual([])
})
it('fills the free slot from the next default rather than leaving it empty', () => {
const s = splitMobileNav([item('dashboard'), item('atlas'), item('journey')], undefined)
expect(ids(s.bar)).toEqual(['dashboard', 'journey', 'atlas'])
expect(ids(s.more)).toEqual([])
})
it('leaves an account that picked its own dock alone', () => {
const s = splitMobileNav(items, { bar: ['vacay', 'atlas'], more: ['journey', 'collections'] })
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
expect(ids(s.more)).toEqual(['journey', 'collections'])
})
it('honours a custom split and order, Dashboard still first', () => {
const s = splitMobileNav(items, { bar: ['journey'], more: ['collections', 'vacay', 'atlas'] })
expect(ids(s.bar)).toEqual(['dashboard', 'journey'])
+24 -10
View File
@@ -35,8 +35,18 @@ const ADDON_ICONS: Record<string, LucideIcon> = {
/** The bar holds Dashboard + at most this many custom items; the rest go to More.
* Two keeps the dock at 3 destinations (Dashboard + 2) so the More slot still fits. */
export const MOBILE_NAV_MAX_BAR = 2
/** The built-in dock next to Dashboard for an un-customised account; everything else starts under More. */
export const DEFAULT_DOCK_IDS = ['vacay', 'atlas']
/**
* The built-in dock next to Dashboard for an un-customised account, in priority
* order: the first MOBILE_NAV_MAX_BAR of these that are actually enabled take the
* slots, everything else starts under More.
*
* Journey outranks Atlas because a phone in the field wants the journal it writes
* to every day, not the map of countries it already visited. Journey ships
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas
* dock it has today. Listing all four known global addons rather than just the
* top two also keeps the dock filled when one of them is switched off.
*/
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections']
export function buildNavItems(
globalAddons: { id: string; name: string; icon: string }[],
@@ -90,10 +100,10 @@ export interface MobileNavSplit {
/**
* Resolve the persisted `{ bar, more }` id split against the live nav items.
* Dashboard is always pinned first in the bar. An empty/absent config falls back
* to the built-in dock (Dashboard + Vacay/Atlas where enabled, everything else
* under More) so an un-customised user sees exactly today's layout. Stored ids
* that no longer resolve are dropped; newly available items are appended under
* More.
* to the built-in dock (Dashboard + the top DEFAULT_DOCK_IDS that are enabled,
* everything else under More). Stored ids that no longer resolve are dropped;
* newly available items are appended under More, so an account that once picked
* its own dock keeps it untouched.
*/
export function splitMobileNav(
items: NavItemDef[],
@@ -102,16 +112,20 @@ export function splitMobileNav(
const dashboard = items.find((i) => i.id === 'dashboard')
const head = dashboard ? [dashboard] : []
const rest = items.filter((i) => i.id !== 'dashboard')
const byId = new Map(rest.map((i) => [i.id, i]))
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((x): x is NavItemDef => !!x)
if (!cfg || (cfg.bar.length === 0 && cfg.more.length === 0)) {
const dock = pick(DEFAULT_DOCK_IDS).slice(0, MOBILE_NAV_MAX_BAR)
const docked = new Set(dock.map((i) => i.id))
return {
bar: [...head, ...rest.filter((i) => DEFAULT_DOCK_IDS.includes(i.id))],
more: rest.filter((i) => !DEFAULT_DOCK_IDS.includes(i.id)),
bar: [...head, ...dock],
// Store order, not DEFAULT_DOCK_IDS order, so More keeps reading like the
// addon list itself.
more: rest.filter((i) => !docked.has(i.id)),
}
}
const byId = new Map(rest.map((i) => [i.id, i]))
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((x): x is NavItemDef => !!x)
const known = new Set([...cfg.bar, ...cfg.more])
const barPicked = pick(cfg.bar)
+2 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import { Navigation, LocateFixed, Locate } from 'lucide-react'
import type { TrackingMode, GeoWatchErrorCode } from '../../hooks/useGeolocation'
import { useTranslation } from '../../i18n'
@@ -77,7 +78,7 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom
cursor: 'pointer',
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
boxShadow: MAP_CONTROL_SHADOW,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -1,5 +1,6 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
@@ -18,7 +19,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
+25 -1
View File
@@ -18,7 +18,13 @@ const mapMock = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })),
// A flat projection, 1000 px per degree: far enough apart that every
// booking line clears its declutter floor, which is measured along the
// projected line (#2275) rather than read off a canned distanceTo.
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => ({
x: lng * 1000, y: lat * 1000,
distanceTo(other: { x: number; y: number }) { return Math.hypot(lng * 1000 - other.x, lat * 1000 - other.y) },
})),
// Panes: jsdom has none, so keep them in a map the pane tests can read back.
panes: new Map<string, HTMLElement>(),
getPane: vi.fn(function (this: void, name: string) { return mapMock.panes.get(name) }),
@@ -221,6 +227,24 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-006b: a caller-coloured route takes its own core and casing', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }]} />)
const [casing, core] = screen.getAllByTestId('polyline')
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
expect(casing.color).toBe('#c2740a')
expect(core.color).toBe('#ff9f0a')
})
it('FE-COMP-MAPVIEW-006c: a line with no colour of its own keeps the route blue', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[undefined]} />)
const [casing, core] = screen.getAllByTestId('polyline')
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
expect(casing.color).toBe('#0a5cc2')
expect(core.color).toBe('#0a84ff')
})
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
render(<MapView route={null} />)
expect(screen.queryByTestId('polyline')).toBeNull()
+11 -3
View File
@@ -606,6 +606,9 @@ export const MapView = memo(function MapView({
places = [],
dayPlaces = [],
route = null,
// One colour pair per entry of `route`, or absent for the blue the route has always
// been. Only the road trip passes these, and only while colouring by day is on.
routeColors = null,
routeSegments = [],
selectedPlaceId = null,
hoverDisabled = false,
@@ -937,7 +940,12 @@ export const MapView = memo(function MapView({
// Store flips state synchronously (instant, works offline); a failed save is logged there.
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
}, [isSatellite, updateSetting])
const switcherBottom = hasDayDetail
// The day panel is a centred card between the two sidebars, so on the desktop
// map it never reaches this pill sitting hard against the left sidebar. Lifting
// it by the panel height only made it hop on every collapse and expand, since
// --day-panel-h changes while hasDayDetail stays true. On a phone the panel is
// full width and does cover the pill, so the lift stays there.
const switcherBottom = isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)'
@@ -1027,7 +1035,7 @@ export const MapView = memo(function MapView({
<Polyline
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
@@ -1041,7 +1049,7 @@ export const MapView = memo(function MapView({
<Polyline
key={`${i}-core`}
positions={seg}
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [])}
@@ -1540,6 +1540,21 @@ describe('MapViewGL', () => {
expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]])
})
it('FE-COMP-MAPVIEWGL-048b: a caller-coloured route rides its colours on the feature', async () => {
const routeSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]}
routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }, undefined]} />)
await act(async () => {})
const { features } = lastData(routeSource)
expect(features[0].properties.color).toBe('#ff9f0a')
expect(features[0].properties.casing).toBe('#c2740a')
// No colour means the day route's own blue, which the layer paint falls back to.
expect(features[1].properties.color).toBeNull()
})
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
const gpxSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null))
@@ -1955,4 +1970,68 @@ describe('MapViewGL', () => {
// in the wrong place.
expect(layer.firstElementChild).not.toBe(handle)
})
// ── Satellite ───────────────────────────────────────────────────────────────
//
// Leaflet has had the imagery for a while and swaps its whole tile layer for it.
// A GL map cannot do that, because its basemap is a style with dozens of layers,
// so the imagery goes on as a raster layer under everything TREK draws.
it('FE-COMP-MAPVIEWGL-074: satellite adds the imagery under the first TREK layer', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({
layers: [{ id: 'background' }, { id: 'road' }, { id: 'trip-route' }, { id: 'trip-gpx-hit' }],
})
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({
type: 'raster',
tiles: [expect.stringContaining('arcgisonline.com')],
}))
// Anchored before the route, or the imagery would be painted over it.
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
'trip-route',
)
})
it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
} as never)
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
})
it('FE-COMP-MAPVIEWGL-076: switching back hides the layer instead of tearing it down', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
// Present from the first pass on, the way the real map reports it afterwards.
glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null))
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null))
const { rerender } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
act(() => {
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
} as never)
})
rerender(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none')
})
})
+144 -9
View File
@@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
@@ -27,7 +28,7 @@ import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
@@ -48,6 +49,21 @@ const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
* layer of its own, drawn over the style and under everything TREK adds — the route,
* the pins and the tracks stay on top and stay legible, which is the same order the
* Leaflet map ends up with.
*
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
*/
const SATELLITE_SOURCE_ID = 'trip-satellite'
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
const OWN_LAYER_PREFIXES = ['trip-', 'trek-']
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -107,6 +123,11 @@ interface Props {
/** 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
/**
* One colour pair per entry of `route`, or absent for the blue the route has always
* been. Only the road trip passes these, and only while colouring by day is on.
*/
routeColors?: ({ line: string; casing: string } | undefined)[] | null
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
onMarkerClick?: (id: number) => void
@@ -233,6 +254,44 @@ function addPlaceClusterLayers(map: any): void {
})
}
/**
* Puts the imagery on the map, or takes it off again.
*
* Built on demand rather than once at load: a style change drops every source the map
* had, and a hot reload does the same. Guarded on its own absence, so the usual pass is
* a no-op and only the visibility flips.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applySatellite(map: any, on: boolean): void {
try {
if (!map.isStyleLoaded?.()) return
if (!map.getSource(SATELLITE_SOURCE_ID)) {
if (!on) return // Nothing to build while it is switched off.
map.addSource(SATELLITE_SOURCE_ID, {
type: 'raster',
tiles: [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: SATELLITE_TILE_MAXZOOM,
attribution: SATELLITE_TILE_ATTRIBUTION,
})
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? []
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: SATELLITE_LAYER_ID,
type: 'raster',
source: SATELLITE_SOURCE_ID,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
}
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
}
} catch { /* a style that refuses the layer keeps the plain basemap */ }
}
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.
@@ -552,6 +611,7 @@ export function MapViewGL({
routeVias = NO_ROUTE_VIAS,
accessLines = NO_ACCESS_LINES,
route = null,
routeColors = null,
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
hoverDisabled = false,
@@ -594,6 +654,15 @@ export function MapViewGL({
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
// The same stored choice the Leaflet map reads, so the two renderers agree.
const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
const isSatellite = baseLayer === 'satellite'
const toggleBaseLayer = useCallback(() => {
// The store flips synchronously, so the map switches even offline; a failed save
// is logged there rather than blocking the switch.
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
}, [isSatellite, updateSetting])
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
@@ -699,6 +768,23 @@ export function MapViewGL({
viaCleanupRef.current = []
viaPinsRef.current.forEach(p => p.remove())
viaPinsRef.current = []
/**
* Zoomed out, the handles go away.
*
* A via is a handle for a few hundred metres of road, and at a continental zoom a
* whole day's worth of them collapses into a cluster of dots over one town — not
* something anybody can aim at, and dragging one there moves the route by kilometres
* per pixel. Below this the drive is read, not shaped.
*/
const VIA_MIN_ZOOM = 9
const applyViaZoom = () => {
const on = map.getZoom() >= VIA_MIN_ZOOM
for (const pin of viaPinsRef.current) {
const node = (pin as { getElement?: () => HTMLElement | null }).getElement?.()
if (node) node.style.display = on ? '' : 'none'
}
}
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'
@@ -787,8 +873,26 @@ export function MapViewGL({
el.removeEventListener('dblclick', swallow)
})
}
// Applied now and on every zoom that settles, so a handle that should not be there is
// gone before the first frame rather than after the first gesture.
applyViaZoom()
map.on('zoomend', applyViaZoom)
viaCleanupRef.current.push(() => map.off('zoomend', applyViaZoom))
}, [vias, viasDraggable, mapReady, glProvider])
/**
* The other people's pointers, and this person's own going the other way.
*
* Built as plain elements on the same pin layer the vias use, so they ride the map
* during a pan instead of being re-placed a frame later — the same reason the via
* handles are not library markers.
*
* No interpolation between frames, unlike the studio's book: a map pans and zooms under
* the arrow, so a position eased towards over several frames is a position that was
* never true at any of them. Ten frames a second placed exactly reads as a hand; the
* same frames chasing a moving target read as a drift.
*/
/**
* The offered routes, drawn under the current one.
*
@@ -1115,7 +1219,9 @@ export function MapViewGL({
id: 'trip-route-casing',
type: 'line',
source: 'trip-route',
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
// Per feature where the caller gave one, else the blue the route has always
// been. `coalesce` rather than a second layer: one source, one stroke.
paint: { 'line-color': ['coalesce', ['get', 'casing'], '#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
@@ -1132,7 +1238,7 @@ export function MapViewGL({
id: 'trip-route-line',
type: 'line',
source: 'trip-route',
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 5 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
@@ -1822,13 +1928,18 @@ export function MapViewGL({
if (!map) return
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
if (!src) return
const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
}))
const features = (route || [])
.map((seg, i) => ({ seg, colors: routeColors?.[i] }))
.filter(({ seg }) => seg && seg.length > 1)
.map(({ seg, colors }) => ({
type: 'Feature' as const,
// Null rather than absent: `coalesce` in the paint expression falls through on
// null, and an absent property would make every line the default colour.
properties: { color: colors?.line ?? null, casing: colors?.casing ?? null },
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
}))
src.setData({ type: 'FeatureCollection', features })
}, [route, mapReady])
}, [route, routeColors, mapReady])
// Update access-spur geojson
useEffect(() => {
@@ -2058,6 +2169,18 @@ export function MapViewGL({
else map.once('load', apply)
}, [userPosition, trackingMode, glProvider])
// Satellite, the same setting the Leaflet map reads, so switching it on one renderer
// and reloading into the other keeps the choice. `styledata` is subscribed because a
// basemap change rebuilds the style from scratch and takes the imagery with it.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const apply = () => applySatellite(map, isSatellite)
apply()
map.on('styledata', apply)
return () => { map.off('styledata', apply) }
}, [isSatellite, mapReady, glProvider])
if (!isMapLibre && !mapboxToken) {
return (
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
@@ -2093,6 +2216,18 @@ export function MapViewGL({
bottomOffset={buttonBottom as unknown as number}
/>
)}
{/* Same pill, same corner, same offsets as the Leaflet map: the switch should not
move when an instance changes renderer. The day panel is a centred card on the
desktop map and never reaches the pill, but on a phone it is full width and
does, which is why the lift is only there. */}
<div style={{
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
bottom: isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
}}>
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
the Leaflet map's overlay (no anchored popup, no photo). */}
{!hoverDisabled && hoverPlace && hoverPos && !isMobile && (
@@ -1,4 +1,4 @@
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-024
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-045
import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
@@ -389,6 +389,43 @@ describe('ReservationOverlay declutter floors and road routes', () => {
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual([[48, 16], [51.5, 16]])
})
it('FE-COMP-RESOVERLAY-043: a routed drive whose ends project close together still draws (#2275)', () => {
// Endpoints 50 px apart, under the 80 px car floor, so the straight hop would
// hide. The road between them loops out 0.3° (300 px) and back, which is what
// is actually drawn — and what the person was missing on their map.
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
const { unmount } = renderOverlay({ reservations: [hop('car', 7, 0.05)] })
expect(lines()).toHaveLength(0)
unmount()
renderOverlay({ reservations: [hop('car', 7, 0.05)], roadRoutes: new Map([[7, road]]) })
expect(lines()).toHaveLength(1)
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual(road)
})
it('FE-COMP-RESOVERLAY-044: a drive with a stop on the way is measured along its legs, not its ends', () => {
// From and to sit 20 px apart; the stop is 400 px away, so the two legs
// together draw 800 px of line.
renderOverlay({
reservations: [booking({
id: 8, type: 'car',
endpoints: [
endpoint({ role: 'from', sequence: 0, name: 'Home', lat: 48, lng: 16 }),
endpoint({ role: 'stop', sequence: 1, name: 'Lake', lat: 48.4, lng: 16 }),
endpoint({ role: 'to', sequence: 2, name: 'Back', lat: 48.02, lng: 16 }),
],
})],
})
expect(lines()).toHaveLength(2)
})
it('FE-COMP-RESOVERLAY-045: a routed drive that really is tiny on screen stays decluttered', () => {
// Every point of the road projects within 30 px: nothing worth drawing yet.
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
renderOverlay({ reservations: [hop('car', 7, 0.03)], roadRoutes: new Map([[7, road]]) })
expect(lines()).toHaveLength(0)
})
it('FE-COMP-RESOVERLAY-036: a booking without an endpoints array is skipped', () => {
renderOverlay({ reservations: [booking({ endpoints: undefined })] })
expect(lines()).toHaveLength(0)
@@ -8,6 +8,7 @@ import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry
import { geodesicArcs } from './flightGeodesy'
import { cleanEndpointName } from './reservationName'
import { useSettingsStore } from '../../store/settingsStore'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import type { Reservation, ReservationEndpoint } from '../../types'
const ENDPOINT_PANE = 'reservation-endpoints'
@@ -122,6 +123,12 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
const road = roadRoutes?.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
}
interface TransportItem {
res: Reservation
from: ReservationEndpoint
@@ -200,26 +207,25 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
}, [reservations])
const visibleItems = useMemo(() => {
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
return items.filter(item => {
// A transit journey draws its real rail/bus alignment, not a straight from->to
// line, so the endpoint-proximity declutter (which exists to hide tiny no-op
// straight connectors) must not suppress it. Otherwise a zoomed-out day — e.g. one
// with no other places to tighten the map onto — hides the whole route (#1570).
if (item.transitSegs.length > 0) return true
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return fromPx.distanceTo(toPx) >= minPx
// Measured along what is drawn, not between the ends: a routed drive can
// cover half the screen while its endpoints sit close together (#2275).
return hopIsVisible(item.type, linesFor(item, roadRoutes), project)
})
}, [items, zoom, map])
}, [items, zoom, map, roadRoutes])
const labelVisibleIds = useMemo(() => {
const set = new Set<number>()
for (const item of visibleItems) {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
if (fromPx.distanceTo(toPx) >= labelFloorPx(item.type)) set.add(item.res.id)
}
return set
}, [visibleItems, zoom, map])
@@ -248,9 +254,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
))
}
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
const road = roadRoutes?.get(item.res.id)
const lines = road && road.length >= 2 ? [road] : item.arcs
return lines.map((seg, segIdx) => (
return linesFor(item, roadRoutes).map((seg, segIdx) => (
<Polyline
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
@@ -1,5 +1,5 @@
import React, { useMemo, useRef } from 'react'
import { Marker, Tooltip } from 'react-leaflet'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Marker, Tooltip, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { useStableVias } from './viaMarkerState'
@@ -31,6 +31,9 @@ interface RoadtripViaMarkersProps {
}
/** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */
/** Below this the handles are not drawn. Shared with the GL renderer's own copy. */
const VIA_MIN_ZOOM = 9
export default function RoadtripViaMarkers({
viasByDay, onMoveVia, onRemoveVia,
}: RoadtripViaMarkersProps): React.ReactElement | null {
@@ -44,6 +47,22 @@ export default function RoadtripViaMarkers({
const handlersRef = useRef({ onMoveVia, onRemoveVia })
handlersRef.current = { onMoveVia, onRemoveVia }
const draggable = !!onMoveVia
/**
* Zoomed out, the handles go away.
*
* A via is a handle for a few hundred metres of road, and at a continental zoom a whole
* day's worth collapses into a cluster of dots over one town — not something anybody can
* aim at, and a drag there moves the route by kilometres per pixel. Below this the drive
* is read, not shaped.
*/
const map = useMap()
const [zoomedIn, setZoomedIn] = useState(() => map.getZoom() >= VIA_MIN_ZOOM)
useEffect(() => {
const update = () => setZoomedIn(map.getZoom() >= VIA_MIN_ZOOM)
update()
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
const markers = useMemo(() => vias.map(via => ({
via,
@@ -62,7 +81,7 @@ export default function RoadtripViaMarkers({
},
})), [vias])
if (!markers.length) return null
if (!markers.length || !zoomedIn) return null
return (
<>
@@ -797,6 +797,119 @@ describe('calculateAlternatives', () => {
expect(asked).toHaveLength(1)
})
// The second engine, which is what made this whole branch produce anything on a
// default install. Every case here overrides the 503 the shared handlers answer with,
// so the OSRM path above stays the one the cases before this measure.
const VALHALLA = 'https://valhalla1.openstreetmap.de/route'
const valhallaAnswer = (summary: Record<string, unknown>) => ({
trip: {
// The detour twoRoutes describes, re-encoded as the polyline6 Valhalla sends.
legs: [{ shape: '_szadB_gjaR~po]_yqwC~po]~tpzA' }],
summary: { length: 130, time: 4500, has_toll: false, has_highway: false, has_ferry: false, ...summary },
},
})
it('FE-COMP-ROUTECALCULATOR-050: one answer from OSRM is offered a second way by Valhalla', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: false })))
)
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(routes).toHaveLength(2)
expect(routes[1].avoids).toBe('motorway')
expect(routes[1].distance).toBeCloseTo(130000, 0)
expect(routes[1].duration).toBe(4500)
})
it('FE-COMP-ROUTECALCULATOR-051: a road that still has the class is not offered as avoiding it', async () => {
// use_highways: 0 is a weighting, so a leg with no way round the motorway comes back
// on the motorway and says so. Offering that as "No motorway" would put a lie on the
// map, so it is dropped and the leg keeps the single way OSRM found.
//
// Asserted on the result rather than on which excludes OSRM was asked: by this point
// `excludeUnsupported` has already recorded the public host from the case above, and
// that Set is module state for the lifetime of the file.
server.use(
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]] })
return new HttpResponse(null, { status: 400 })
}),
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true, has_ferry: true })))
)
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52.5, lng: 11.5 })
expect(routes).toHaveLength(1)
expect(routes[0].avoids).toBeUndefined()
})
it('FE-COMP-ROUTECALCULATOR-052: a ferry-free way is offered, which OSRM alone never managed', async () => {
server.use(
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]] })
return new HttpResponse(null, { status: 400 })
}),
http.post(VALHALLA, async ({ request }) => {
const body = await request.json() as { costing_options: { auto: Record<string, number> } }
// Only the ferry question gets a usable answer, so the first two fall through.
if (!('use_ferry' in body.costing_options.auto)) {
return HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true }))
}
return HttpResponse.json(valhallaAnswer({ has_ferry: false }))
})
)
const routes = await calculateAlternatives({ lat: 54, lng: 10 }, { lat: 55, lng: 12 })
expect(routes).toHaveLength(2)
expect(routes[1].avoids).toBe('ferry')
})
it('FE-COMP-ROUTECALCULATOR-054: the same road from the other engine is not offered as a second one', async () => {
// The reason the geometry decides rather than the numbers. Valhalla prices roads
// differently, so on a long leg the SAME road comes back kilometres apart in both
// length and time — here 13 km and 5 minutes, which is the measured spread between
// the two engines and comfortably past the 200 m the numbers used to allow. Judged
// on those it reads as a discovery and puts a second blue line on top of the first.
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
http.post(VALHALLA, () => HttpResponse.json({
trip: {
// twoRoutes.routes[0]'s own line, re-encoded: the identical road.
legs: [{ shape: '_szadB_gjaR~po]_qo]~po]_qo]' }],
summary: { length: 113, time: 3900, has_toll: false, has_highway: false, has_ferry: false },
},
}))
)
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(routes).toHaveLength(1)
})
it('FE-COMP-ROUTECALCULATOR-053: an instance with its own OSRM is never sent to the public Valhalla', async () => {
let valhallaCalls = 0
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org' } }))
server.use(
http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
}),
http.post(VALHALLA, () => { valhallaCalls++; return HttpResponse.json(valhallaAnswer({})) })
)
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 12 })
expect(valhallaCalls).toBe(0)
expect(routes[1].avoids).toBe('motorway')
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
})
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
+268 -26
View File
@@ -3,6 +3,9 @@ import { pluginsApi } from '../../api/client'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute'
import type { RouteUsageSurface } from '@trek/shared'
// 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
@@ -50,6 +53,44 @@ function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
}
/**
* `fetch`, with the request counted.
*
* Every route TREK draws goes out from here, and nowhere else, which makes this the one
* place that can answer how much routing an instance really does — the number behind
* "could we host an engine ourselves". Counted: how many requests, of what kind, how
* many waypoints, roughly how far, and whether the host refused. Not counted, because it
* is never sent: where any of it was.
*
* Distance is the straight line along the waypoint chain rather than the routed length,
* which is only in the answer and differs per response shape. It is a floor on the real
* figure, and a floor is enough to tell a 1500 km per-request limit from a 150 km one.
*
* An aborted request is not a failure: the map cancels constantly while someone drags.
*/
async function routedFetch(
url: string,
signal: AbortSignal | undefined,
kind: RouteUsageSurface,
profile: 'driving' | 'walking' | 'cycling',
waypoints: readonly Waypoint[],
): Promise<Response> {
const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim()
let km = 0
for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i])
const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km }
try {
const response = await fetch(url, { signal })
countRoute({ ...sample, failed: !response.ok })
return response
} catch (err) {
if (!(err instanceof DOMException && err.name === 'AbortError')) {
countRoute({ ...sample, failed: true })
}
throw err
}
}
/**
* A routing request the host refused, as opposed to one it answered with "no route".
*
@@ -114,7 +155,7 @@ export async function calculateRoute(
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'route', profile, waypoints)
if (!response.ok) {
throw new RoutingRefusedError(response.status, retryAfterMs(response))
}
@@ -321,7 +362,7 @@ export async function calculateSegments(
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'segments', 'driving', waypoints)
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -352,23 +393,55 @@ export async function calculateSegments(
*/
export async function calculateRouteWithLegs(
waypoints: Waypoint[],
{ signal, profile = 'driving', tripId, dayId }: { signal?: AbortSignal; profile?: RouteProfileKey; tripId?: number | string | null; dayId?: number | null } = {}
{ signal, profile = 'driving', tripId, dayId, avoid = [] }: {
signal?: AbortSignal
profile?: RouteProfileKey
tripId?: number | string | null
dayId?: number | null
/** Road classes to weight away. Only the driving profile has any, and only Valhalla can. */
avoid?: readonly AvoidClass[]
} = {}
): Promise<RouteWithLegs> {
if (!waypoints || waypoints.length < 2) {
return { coordinates: [], distance: 0, duration: 0, legs: [] }
}
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
? [...avoid].sort()
: []
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
// The cached result carries formatted leg distances, so the active distance unit is
// part of the key — otherwise switching km↔mi would return stale text (#1300).
// A plugin route is trip-/day-specific (it may return different charging stops for
// the same coordinates on a different day), so its key includes tripId/dayId;
// the built-in OSRM profiles are context-free and leave those out.
//
// The avoidance belongs in the key for two reasons pointing opposite ways. Without it
// turning the switch on returns the tolled route out of cache in under a millisecond,
// so nothing on screen changes and it reads as a broken toggle. And the leak the other
// way is worse: this Map is shared with the ordinary day planner, the studio and the
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
// under the plain key would hand them a road nobody drives and a second engine's
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key
// also separates the two engines.
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
const cached = routeCache.get(cacheKey)
if (cached) return cached
if (avoiding.length) {
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
// day drawn rather than blank, and `avoidance` stays absent on that result so the
// rail can say the avoidance did not happen instead of implying it did.
if (routed) {
cacheRoute(cacheKey, routed)
return routed
}
}
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
// and normalizes its answer; null means the provider failed or refused, and the
// throw makes callers fall back to straight lines exactly like an OSRM outage.
@@ -416,7 +489,7 @@ export async function calculateRouteWithLegs(
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 })
const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints)
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -446,12 +519,73 @@ export async function calculateRouteWithLegs(
const snapped = readSnapped(data, waypoints)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
routeCache.set(cacheKey, result)
cacheRoute(cacheKey, result)
return result
}
/** Store a route and drop the oldest once the cache is over its cap. */
function cacheRoute(key: string, route: RouteWithLegs): void {
routeCache.set(key, route)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
return result
}
/**
* A whole chain from the second engine, weighted away from some road classes.
*
* Shaped exactly like the OSRM answer above so the callers never branch on which engine
* spoke — same leg list, same formatted text, same snap positions. What it adds is
* `avoidance`: what was asked for against what the road actually turned out to be, which
* is the only honest way to label a weighting that can fail.
*
* Null rather than a throw when it cannot answer, so the caller falls through to OSRM
* and the day stays drawn.
*/
async function routeAvoidingWithLegs(
waypoints: Waypoint[],
avoid: AvoidClass[],
signal?: AbortSignal,
): Promise<RouteWithLegs | null> {
const sample = {
profile: 'driving' as const,
surface: 'legs' as const,
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
waypoints: waypoints.length,
km: waypoints.slice(1).reduce((sum, point, i) => sum + haversineKm(waypoints[i], point), 0),
}
const run = await valhallaRun(waypoints, 'driving', avoid, signal)
// Counted by hand because the adapter speaks to a different engine with a different
// verb, and the counter exists to answer "how much routing does this instance do" —
// an engine it cannot see is exactly the traffic that would go missing from it.
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
if (!run) return null
const legs: RouteSegment[] = run.legs.map((leg, i): RouteSegment => {
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
return {
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
from,
to,
distance: leg.distance,
duration: leg.duration,
walkingText: formatDuration(leg.distance / (5000 / 3600)),
drivingText: formatDuration(leg.duration),
distanceText: formatRouteDistance(leg.distance),
durationText: formatDuration(leg.duration),
}
})
return {
coordinates: run.total.coordinates,
distance: run.total.distance,
duration: run.total.duration,
legs,
snapped: run.snapped,
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
}
}
/**
@@ -514,7 +648,7 @@ export interface RouteAlternative {
/** 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'
avoids?: AvoidClass
}
/**
@@ -528,11 +662,39 @@ export interface RouteAlternative {
*
* Only for driving: the foot and bike profiles have no excludable classes, and asking
* anyway earns an `InvalidOptions` for nothing.
*
* Ferry is on the list for Valhalla's sake and skipped on the OSRM path below. OSRM
* does carry a ferry class, but on a leg where a ferry is the only crossing it answers
* NoRoute rather than the long way round, which is not an alternative anybody can use.
* Valhalla weights it instead and drives around, which is the whole point.
*/
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
/** Two lines are the same road if their length and their duration agree closely enough. */
/**
* How far two lines may stray from each other and still be the same road, in kilometres.
*
* Parallel carriageways, a different slip road onto the same motorway and the noise
* between two engines' idea of where a road is all sit well under this. A way somebody
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
*/
const SAME_ROAD_KM = 0.5
/**
* Two lines are the same road.
*
* Length and duration used to decide this, and stopped being enough when a second engine
* started answering. Valhalla prices roads differently — measured over ten European legs
* its distances ran 2.8 % above OSRM's and its times 5.4 % — so on a 470 km leg the same
* road comes back thirteen kilometres apart and a 200 m tolerance calls it a discovery.
* Two identical blue lines on the map is exactly what that looks like.
*
* The geometry does not care which engine drew it, so it decides whenever there is one.
* The numbers stay as the fallback for a line with no shape to compare.
*/
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
const stray = furthestFrom(b.coordinates, a.coordinates)
if (stray) return stray.km < SAME_ROAD_KM
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
}
@@ -563,16 +725,76 @@ const excludeUnsupported = new Set<string>()
* particular question had no answer.
*/
async function routeExcluding(
coords: string,
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
exclude: string,
exclude: AvoidClass,
signal?: AbortSignal,
): Promise<RouteAlternative | null> {
// Valhalla first, because on a default install it is the only one of the two that
// can answer at all. It weights the class away instead of banning it, so it also
// answers on a leg where the class cannot be avoided — and says so, which is why the
// result is thrown away below unless the road really came back without it.
const weighted = await valhallaExcluding(from, to, profile, exclude, signal)
if (weighted) return weighted
// OSRM has a ferry class too, but on a leg where the ferry IS the crossing it answers
// NoRoute rather than driving round, so there is nothing to offer and no point paying
// a request to be told so.
if (exclude === 'ferry') return null
return osrmExcluding(from, to, profile, exclude, signal)
}
/**
* The same leg from Valhalla, weighted away from one class, or null.
*
* The check that makes this honest is `legAvoids`: `use_tolls: 0` is a preference, so
* between two points with no untolled connection the answer is still a tolled road.
* Offering that as "No tolls" would put a lie on the map, so it is dropped and the
* OSRM attempt gets its turn.
*/
async function valhallaExcluding(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
exclude: AvoidClass,
signal?: AbortSignal,
): Promise<RouteAlternative | null> {
if (!valhallaAvailable()) return null
const sample = {
profile,
surface: 'alternatives' as const,
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
waypoints: 2,
km: haversineKm(from, to),
}
const leg = await valhallaRouteAvoiding(from, to, profile, exclude, signal)
// Counted here rather than in the adapter, so the adapter stays a format translator
// and every routing request an instance makes still lands in the one counter.
if (!signal?.aborted) countRoute({ ...sample, failed: !leg })
if (!leg || !legAvoids(leg, exclude)) return null
return {
coordinates: leg.coordinates,
distance: leg.distance,
duration: leg.duration,
divergence: null,
}
}
/** The same leg from OSRM with the class excluded outright, or null. */
async function osrmExcluding(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
exclude: AvoidClass,
signal?: AbortSignal,
): Promise<RouteAlternative | null> {
const base = routeBaseFor(profile)
if (excludeUnsupported.has(base)) return null
try {
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
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.
@@ -614,7 +836,7 @@ export async function calculateAlternatives(
): 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 })
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -629,19 +851,18 @@ export async function calculateAlternatives(
// 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.
// the same drive without the motorway, then without the tolls, then without a ferry.
// 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.
// This used to produce nothing at all on a default install: OSRM's `exclude` is HTTP
// 400 on both public hosts, whose car profile is built without excludable classes. It
// now asks Valhalla first, where avoidance is a runtime weighting, and only falls back
// to OSRM's `exclude` for an instance running its own MLD-built router.
if (routes.length < 2 && profile === 'driving') {
for (const exclude of EXCLUDABLE_CLASSES) {
if (signal?.aborted) break
const detour = await routeExcluding(coords, profile, exclude, signal)
const detour = await routeExcluding(from, to, profile, exclude, signal)
if (detour && !routes.some(r => sameRoad(r, detour))) {
detour.avoids = exclude
routes.push(detour)
@@ -659,22 +880,43 @@ export async function calculateAlternatives(
/** 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 {
const furthest = furthestFrom(line, reference)
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null
}
/**
* The same point, plus how far off it actually is.
*
* The search itself stays in squared degrees with a cosine on the longitude: it only has
* to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
* real distance rather than a rank, and half a degree is a different number of metres in
* Andalusia than in Lapland.
*/
function furthestFrom(
line: [number, number][],
reference: [number, number][],
): { lat: number; lng: number; km: 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 bestNeighbour: [number, 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
let nearestPoint: [number, number] | null = null
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 (d < nearest) { nearest = d; nearestPoint = reference[j] }
}
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
}
return best
if (!best) return null
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { ...best, km }
}
@@ -0,0 +1,90 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from './TripRouteOverview'
import type { TripRouteOverview } from './useTripRouteOverview'
const overview = (over: Partial<TripRouteOverview> = {}): TripRouteOverview => ({
days: [
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: { line: '#ff9f0a', casing: '#c2740a' }, lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
],
lines: [], lineColors: [], segments: [], focusPoints: [],
totalDistance: 222500, totalDuration: 10800, loading: false,
...over,
})
describe('TripRouteOverviewPill', () => {
it('FE-MAP-TROU-001: says which way it will take you, and reports its state', () => {
const onToggle = vi.fn()
const { rerender } = render(<TripRouteOverviewPill active={false} onToggle={onToggle} />)
const button = screen.getByTestId('trip-overview-pill')
expect(button).toHaveAttribute('aria-pressed', 'false')
expect(button).toHaveAttribute('aria-label', 'map.overview.show')
fireEvent.click(button)
expect(onToggle).toHaveBeenCalledTimes(1)
rerender(<TripRouteOverviewPill active onToggle={onToggle} />)
expect(screen.getByTestId('trip-overview-pill')).toHaveAttribute('aria-label', 'map.overview.hide')
})
})
describe('TripRouteOverviewPanel', () => {
it('FE-MAP-TROU-002: leads with the trip total in the reader\'s own unit', () => {
const { rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(screen.getByText('222.5 km')).toBeInTheDocument()
rerender(<TripRouteOverviewPanel overview={overview()} unit="imperial" />)
expect(screen.getByText('138.3 mi')).toBeInTheDocument()
})
it('FE-MAP-TROU-003: names every day, its distance and how it is travelled', () => {
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(screen.getByText('dayplan.dayN')).toBeInTheDocument()
// A day with a title is named by it rather than by its number.
expect(screen.getByText('Coast road')).toBeInTheDocument()
expect(screen.getByText('82 km')).toBeInTheDocument()
expect(screen.getByText('140.5 km')).toBeInTheDocument()
})
it('FE-MAP-TROU-004: marks the total as partial while legs are still coming in', () => {
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview({ loading: true })} unit="metric" />)
expect(container.textContent).toContain('…')
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(container.textContent).not.toContain('…')
})
it('FE-MAP-TROU-005: a day row picks that day when the shell offers it', () => {
const onSelectDay = vi.fn()
render(<TripRouteOverviewPanel overview={overview()} unit="metric" selectedDayId={2} onSelectDay={onSelectDay} />)
fireEvent.click(screen.getByText('Coast road'))
expect(onSelectDay).toHaveBeenCalledWith(2)
})
it('FE-MAP-TROU-006: read-only shells get rows, not buttons', () => {
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('FE-MAP-TROU-008: hugs its rows, and takes the cap the shell gives it', () => {
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
const card = () => screen.getByTestId('trip-overview-panel')
expect(card().style.width).toBe('fit-content')
expect(card().style.maxWidth).toBe('320px')
// The phone shell asks for a narrower card so the map keeps the space.
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" maxWidth={240} />)
expect(card().style.maxWidth).toBe('240px')
expect(container).not.toBeEmptyDOMElement()
})
it('FE-MAP-TROU-007: draws nothing at all for a trip with no routed day', () => {
const { container } = render(<TripRouteOverviewPanel overview={overview({ days: [], totalDistance: 0 })} unit="metric" />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,130 @@
import { Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
import { formatDistance } from '../../utils/units'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { TripRouteOverview as Overview } from './useTripRouteOverview'
import type { DistanceUnit } from '../../types'
/**
* Toggle for the whole-trip route overview (#1736). Same frosted shell as the other
* map controls, so it lines up with the compass and the layer switcher.
*/
export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; onToggle: () => void }) {
const { t } = useTranslation()
const label = active ? t('map.overview.hide') : t('map.overview.show')
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
onClick={onToggle}
aria-label={label}
title={label}
aria-pressed={active}
data-testid="trip-overview-pill"
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Route size={17} strokeWidth={2} />
</button>
</div>
)
}
/**
* The trip's stages and its total distance, read off the overview the map is drawing.
*
* One component for both shells: the phone and the desktop place it differently but the
* rows are the same rows, and a second copy of them is a second thing to keep in step.
*/
export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelectDay, maxWidth = 320 }: {
overview: Overview
unit: DistanceUnit
selectedDayId?: number | null
onSelectDay?: (dayId: number) => void
/** How wide the card may grow before day names start to ellipsize. */
maxWidth?: number
}) {
const { t } = useTranslation()
if (!overview.days.length) return null
return (
<div
data-testid="trip-overview-panel"
style={{
pointerEvents: 'auto', borderRadius: 14, overflow: 'hidden',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
// Sized to its rows rather than to the space available: a trip of short day
// names left a band of empty card over the map, which on a phone is most of
// what there is to look at.
width: 'fit-content', maxWidth,
}}
>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, padding: '10px 14px 8px' }}>
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', textTransform: 'uppercase', letterSpacing: 0.4 }}>
{t('map.overview.total')}
</span>
{/* Same tier as the label beside it: the two read as one line rather than as a
heading with a number stuck under it. */}
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600 }}>
{formatDistance(overview.totalDistance / 1000, unit)}
{/* Still routing: the number is a partial sum, and saying so beats a total
that silently grows while you read it. */}
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
</span>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', borderTop: '1px solid var(--border-primary)' }}>
{overview.days.map(day => {
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
const row = (
<>
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
<span className="text-content" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }} className="text-content-muted">
{day.modes.map(mode => {
const Icon = profileIcon(mode)
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
})}
</span>
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
{formatDistance(day.distance / 1000, unit)}
</span>
</>
)
const style = {
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 14px', textAlign: 'left' as const,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
background: day.dayId === selectedDayId ? 'var(--bg-hover)' : 'transparent',
border: 'none',
}
return onSelectDay ? (
<button key={day.dayId} type="button" onClick={() => onSelectDay(day.dayId)} style={{ ...style, cursor: 'pointer' }}>
{row}
</button>
) : (
<div key={day.dayId} style={style}>{row}</div>
)
})}
</div>
</div>
)
}
@@ -10,8 +10,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import type * as L from 'leaflet'
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
import { VectorBasemap, attachVectorBasemap, hideLabelLayers, type GlLeafletLayer } from './VectorBasemap'
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION } from '../../constants/mapDefaults'
import { hasWebGL } from '../../utils/webgl'
import {
VectorBasemap,
attachVectorBasemap,
detachBasemapLayer,
hideLabelLayers,
restyleBasemap,
type BasemapLayer,
type GlLeafletLayer,
} from './VectorBasemap'
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION, RASTER_FALLBACK_TILE_URL } from '../../constants/mapDefaults'
const addAttribution = vi.fn()
/** Only the two things the component touches; casting keeps the mock honest about that. */
@@ -20,7 +29,16 @@ const leafletMap = asMap({ attributionControl: { addAttribution } })
/** A map built without an attribution control, like the atlas. */
const bareMap = asMap({})
vi.mock('react-leaflet', () => ({ useMap: () => leafletMap }))
// TileLayer as well as useMap: the component renders one when the browser has no
// WebGL, and jsdom is exactly such a browser.
vi.mock('react-leaflet', () => ({
useMap: () => leafletMap,
TileLayer: ({ url }: { url: string }) => <div data-testid="raster-basemap" data-url={url} />,
}))
// jsdom has no WebGL, so without this every case below would take the raster
// branch. The suite is about the GL wiring; the raster branch has its own cases.
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
const maplibreGL = vi.fn(() => {
@@ -201,3 +219,91 @@ describe('hideLabelLayers', () => {
expect(() => hideLabelLayers(layer)).not.toThrow()
})
})
/**
* A browser that will not give MapLibre a WebGL context (#2288).
*
* Hardware acceleration off, a blocklisted driver, a VM or a remote desktop all
* land here. Before the probe, the throw came out of an async effect nobody could
* catch, and the layer it left behind took the page down on the way out.
*/
describe('VectorBasemap without WebGL', () => {
const noWebGL = vi.mocked(hasWebGL)
/** A map real Leaflet will accept, which the raster stand-in needs. */
const rasterMap = () => asMap({ addLayer: vi.fn(), attributionControl: { addAttribution } })
beforeEach(() => {
noWebGL.mockReturnValue(false)
})
it('FE-COMP-VECBM-013: draws raster tiles and never asks for the maplibre chunk', async () => {
// Not loading it is half the point: about a megabyte for a browser that could
// never have drawn it.
const { findByTestId } = render(<VectorBasemap style={OFM_POSITRON} />)
const tiles = await findByTestId('raster-basemap')
expect(tiles.getAttribute('data-url')).toBe(RASTER_FALLBACK_TILE_URL)
expect(maplibreGL).not.toHaveBeenCalled()
})
it('FE-COMP-VECBM-014: unmounting is quiet, with nothing to take off the map', () => {
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
// The crash in #2288 came from teardown, not from the failed attach.
expect(() => unmount()).not.toThrow()
})
it('FE-COMP-VECBM-015: the imperative maps get the raster stand-in in their ref', async () => {
const ref: { current: BasemapLayer | null } = { current: null }
const map = rasterMap()
await attachVectorBasemap(map, OFM_POSITRON, ref, () => false)
expect(maplibreGL).not.toHaveBeenCalled()
expect(ref.current).not.toBeNull()
// OpenStreetMap rather than OpenFreeMap: different tiles, different credit.
expect(addAttribution).toHaveBeenCalledWith(expect.stringContaining('openstreetmap.org/copyright'))
})
it('FE-COMP-VECBM-016: a map torn down mid-probe still gets nothing', async () => {
const ref: { current: BasemapLayer | null } = { current: null }
await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => true)
expect(ref.current).toBeNull()
})
it('FE-COMP-VECBM-017: the raster stand-in ignores a restyle and a label sweep', async () => {
const ref: { current: BasemapLayer | null } = { current: null }
await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => false)
// Both are GL-only operations. Called on raster tiles they must do nothing
// rather than reach for a maplibre map that is not there.
expect(() => restyleBasemap(ref.current, OFM_DARK)).not.toThrow()
expect(() => hideLabelLayers(ref.current!)).not.toThrow()
})
})
describe('detachBasemapLayer', () => {
it('FE-COMP-VECBM-018: a GL layer whose context never came off cleanly anyway', () => {
// This is the crash from #2288 in one line: maplibre-gl-leaflet's onRemove
// calls this._glMap.remove() without asking whether there is one, and after a
// refused context there is not. It ran inside a React cleanup, so the throw
// reached a boundary and replaced the page.
const remove = vi.fn(function (this: { _glMap?: { remove: () => void } }) {
this._glMap!.remove()
})
const halfBuilt = { getMaplibreMap: () => undefined, remove } as unknown as GlLeafletLayer
expect(() => detachBasemapLayer(halfBuilt)).not.toThrow()
expect(remove).toHaveBeenCalled()
})
it('FE-COMP-VECBM-019: a layer that throws for any other reason is swallowed too', () => {
// Unmounting a map is never allowed to fail because its basemap did.
const layer = { remove: vi.fn(() => { throw new Error('boom') }) } as unknown as BasemapLayer
expect(() => detachBasemapLayer(layer)).not.toThrow()
})
it('FE-COMP-VECBM-020: nothing to detach is not an error', () => {
expect(() => detachBasemapLayer(null)).not.toThrow()
})
})
+196 -30
View File
@@ -1,12 +1,30 @@
import { useEffect, useRef } from 'react'
import { useMap } from 'react-leaflet'
import type * as L from 'leaflet'
import { useEffect, useRef, useState } from 'react'
import { TileLayer, useMap } from 'react-leaflet'
import L from 'leaflet'
import type { MaplibreGL } from '@maplibre/maplibre-gl-leaflet'
import { attributionForTile } from '../../constants/mapDefaults'
import {
RASTER_FALLBACK_MAX_ZOOM,
RASTER_FALLBACK_TILE_URL,
attributionForTile,
} from '../../constants/mapDefaults'
import { hasWebGL } from '../../utils/webgl'
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
/** The Leaflet layer maplibre-gl-leaflet hands back. */
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
/**
* What a map ended up drawing with. Every caller keeps one ref for both kinds, so
* there is no code path that knows only about the GL layer and falls over the
* raster stand-in the moment a browser has no WebGL (#2288).
*/
export type BasemapLayer = GlLeafletLayer | L.TileLayer
/** A GL layer rather than the raster stand-in. Prototype method, so `in` finds it. */
function isGlLayer(layer: BasemapLayer): layer is GlLeafletLayer {
return 'getMaplibreMap' in layer
}
/**
* Load the layer factory.
*
@@ -28,11 +46,105 @@ async function loadLayerFactory() {
return bridge.maplibreGL
}
/** The factory, or null when its chunk never arrived. A dead chunk is not the basemap's problem. */
async function loadLayerFactoryOrNull() {
try {
return await loadLayerFactory()
} catch (err) {
// A chunk gone after a deploy is the global handler's case and heals itself
// with one reload; here it only means there is no GL layer to attach.
if (isChunkLoadError(err)) reloadOnceForChunk()
else console.warn('[basemap] the maplibre chunk did not load', err)
return null
}
}
/** Credit the basemap on a map that has an attribution control; a no-op on one that does not. */
function creditBasemap(map: L.Map, style: string): void {
map.attributionControl?.addAttribution(attributionForTile(style))
}
/**
* Make a half-built GL layer safe to take off a map.
*
* maplibre-gl-leaflet's onRemove calls `this._glMap.remove()` without asking
* whether there is one, and after a refused WebGL context there is not. That
* second throw is what turned a missing basemap into a crash: it lands in a React
* cleanup, where a boundary picks it up and replaces the page (#2288). A no-op
* stand-in lets the bridge run the rest of its own teardown, the pane child and
* the zoom-animation listener, and skip only the line that cannot work. A healthy
* layer already has a GL map and is left alone.
*/
function disarmGlLayer(layer: GlLeafletLayer): void {
const internals = layer as unknown as { _glMap?: { remove: () => void } | null }
if (!internals._glMap) internals._glMap = { remove: () => {} }
}
/**
* Hang a GL layer into a Leaflet map and say whether it took.
*
* maplibre-gl builds its WebGL context inside the Map constructor, which Leaflet
* reaches through onAdd, so `addTo` is where a browser without WebGL throws. The
* probe catches nearly every case; what is left is a context budget that ran out
* between probe and attach, which is real on a page holding several maps. Either
* way the layer has to come back off: Leaflet registers it before onAdd runs, so
* a broken one left behind is handed to the retile effect and to every later
* map.remove().
*/
function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolean {
try {
layer.addTo(map)
} catch (err) {
console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err)
detachBasemapLayer(layer)
return false
}
creditBasemap(map, style)
return true
}
/**
* The raster stand-in, attached imperatively for the maps that build Leaflet
* themselves. The react-leaflet callers get the same tiles through the <TileLayer>
* below instead, the way they already draw a user's own raster template.
*/
function attachRasterFallback(map: L.Map): L.TileLayer {
const layer = L.tileLayer(RASTER_FALLBACK_TILE_URL, {
maxZoom: RASTER_FALLBACK_MAX_ZOOM,
referrerPolicy: 'strict-origin-when-cross-origin',
} as L.TileLayerOptions)
layer.addTo(map)
creditBasemap(map, RASTER_FALLBACK_TILE_URL)
return layer
}
/**
* Take a basemap off its map. Never throws: unmounting a map is not allowed to
* fail because its basemap did (#2288).
*/
export function detachBasemapLayer(layer: BasemapLayer | null | undefined): void {
if (!layer) return
if (isGlLayer(layer)) disarmGlLayer(layer)
try {
layer.remove()
} catch (err) {
console.warn('[basemap] teardown failed, leaving the layer where it is', err)
}
}
/**
* Retile in place. Swapping the layer would drop a WebGL context per theme
* toggle, and browsers cap those in the low teens.
*
* The raster stand-in has nothing to swap: it draws one template, and there is no
* keyless dark OSM raster to switch to, so a theme change leaves it alone rather
* than refetching the same tiles.
*/
export function restyleBasemap(layer: BasemapLayer | null | undefined, style: string): void {
if (!layer || !isGlLayer(layer)) return
layer.getMaplibreMap()?.setStyle(style)
}
/**
* A MapLibre vector style as the basemap of a Leaflet map.
*
@@ -40,21 +152,37 @@ function creditBasemap(map: L.Map, style: string): void {
* watermark unless the operator has requested a key by mail, so the basemap of
* every Leaflet map here is a style document rather than a tile template.
* maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which
* leaves everything above it alone: markers, GeoJSON, clusters, plugin layers
* and the panes they live in are untouched, and Leaflet's CSS puts
* leaves everything above it alone: markers, GeoJSON, clusters, plugin layers and
* the panes they live in are untouched, and Leaflet's CSS puts
* `pointer-events: none` on the layer's canvas, so clicks fall through to them.
*
* WebGL is not a given, though. Hardware acceleration switched off, a blocklisted
* driver, a VM or a remote desktop all end with maplibre-gl throwing out of its
* constructor, so the browser is asked first and gets raster tiles when the answer
* is no (#2288). Asking is also what keeps the maplibre chunk off the wire for a
* browser that could never have used it.
*/
export function VectorBasemap({ style }: { style: string }): null {
export function VectorBasemap({ style }: { style: string }) {
const map = useMap()
const layerRef = useRef<GlLeafletLayer | null>(null)
const styleRef = useRef(style)
styleRef.current = style
// State rather than a plain constant: the probe can say yes and the attach can
// still fail once the page has spent its context budget, and that has to reach
// the render or the raster tiles never appear.
const [glUsable, setGlUsable] = useState(hasWebGL)
useEffect(() => {
if (!glUsable) return
let cancelled = false
void (async () => {
const maplibreGL = await loadLayerFactory()
const maplibreGL = await loadLayerFactoryOrNull()
if (cancelled) return
if (!maplibreGL) {
setGlUsable(false)
return
}
// The style is read through a ref, never from the dependency list: a style
// change must retile in place rather than tear the layer down, the same
@@ -64,68 +192,106 @@ export function VectorBasemap({ style }: { style: string }): null {
interactive: false,
attributionControl: false,
})
if (!attachGlLayer(map, layer, styleRef.current)) {
setGlUsable(false)
return
}
layerRef.current = layer
layer.addTo(map)
creditBasemap(map, styleRef.current)
})()
return () => {
cancelled = true
layerRef.current?.remove()
detachBasemapLayer(layerRef.current)
layerRef.current = null
}
}, [map])
}, [map, glUsable])
// Restyle in place. Swapping the layer would drop a WebGL context per theme
// toggle, and browsers cap those in the low teens.
useEffect(() => {
layerRef.current?.getMaplibreMap()?.setStyle(style)
restyleBasemap(layerRef.current, style)
}, [style])
// No WebGL, no vector tiles. Raster keeps the map readable instead of leaving
// markers, routes and clusters floating over grey; see mapDefaults for why this
// particular source. The satellite toggle in MapView was always raster and is
// unaffected either way.
if (!glUsable) {
return (
<TileLayer
key="webgl-fallback"
url={RASTER_FALLBACK_TILE_URL}
attribution={attributionForTile(RASTER_FALLBACK_TILE_URL)}
maxZoom={RASTER_FALLBACK_MAX_ZOOM}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
)
}
return null
}
export default VectorBasemap
/**
* The same layer for the maps that build Leaflet imperatively rather than
* The same basemap for the maps that build Leaflet imperatively rather than
* through react-leaflet: the journey map and the atlas.
*
* Loading is async because maplibre-gl only ever arrives through a dynamic
* import, so the caller passes a `cancelled` probe: a map torn down mid-load
* must not get a layer attached to it afterwards.
* Loading is async because maplibre-gl only ever arrives through a dynamic import,
* so the caller passes a `cancelled` probe: a map torn down mid-load must not get
* a layer attached to it afterwards. What lands in `ref` is either the GL layer or
* the raster stand-in, which is why callers go through restyleBasemap() and
* detachBasemapLayer() rather than reaching for getMaplibreMap() themselves.
*/
export async function attachVectorBasemap(
map: L.Map,
style: string,
ref: { current: GlLeafletLayer | null },
ref: { current: BasemapLayer | null },
cancelled: () => boolean,
opts: { hideLabels?: boolean } = {},
): Promise<void> {
const maplibreGL = await loadLayerFactory()
// Same gate as the component: a browser without WebGL never pays for the
// maplibre chunk and gets raster tiles rather than an empty map (#2288).
if (!hasWebGL()) {
if (!cancelled()) ref.current = attachRasterFallback(map)
return
}
const maplibreGL = await loadLayerFactoryOrNull()
if (cancelled()) return
if (!maplibreGL) {
ref.current = attachRasterFallback(map)
return
}
const layer = maplibreGL({ style, interactive: false, attributionControl: false })
ref.current = layer
layer.addTo(map)
// The raster layer this replaces carried the credit, and OpenFreeMap asks for
// one of its own.
creditBasemap(map, style)
// one of its own; attachGlLayer adds it once the layer is actually on the map.
if (!attachGlLayer(map, layer, style)) {
ref.current = attachRasterFallback(map)
return
}
ref.current = layer
if (opts.hideLabels) hideLabelLayers(layer)
}
/**
* Drop every label from a vector style.
*
* The atlas draws country names into its own fills, so a basemap that repeats
* them underneath is noise. OpenFreeMap has no label-free style, but its labels
* are all `symbol` layers, while borders and coastlines are `line` and `fill`,
* so hiding that one type leaves the geography intact.
* The atlas draws country names into its own fills, so a basemap that repeats them
* underneath is noise. OpenFreeMap has no label-free style, but its labels are all
* `symbol` layers, while borders and coastlines are `line` and `fill`, so hiding
* that one type leaves the geography intact.
*
* Bound to `style.load` rather than run once: the event fires again after every
* setStyle, so a theme switch keeps the labels off without a second call site.
*/
export function hideLabelLayers(layer: GlLeafletLayer): void {
export function hideLabelLayers(layer: BasemapLayer): void {
// Nothing to hide on the raster stand-in: OSM bakes its labels into the tile.
// A WebGL-less atlas therefore shows country names twice, which is cosmetic and
// the price of having a basemap at all, because the only keyless alternative is
// none.
if (!isGlLayer(layer)) return
const gl = layer.getMaplibreMap()
if (!gl) return
const apply = () => {
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { buildDayRouteRuns } from './dayRoutePlan'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
const at = (lat: number, lng: number, order: number, extra: Record<string, unknown> = {}) =>
buildAssignment({ day_id: 1, order_index: order, place: buildPlace({ lat, lng }), ...extra })
const inputs = (over: Partial<{
days: Day[]; assignments: AssignmentsMap; reservations: Reservation[]
accommodations: Accommodation[]; optimizeFromAccommodation: boolean | undefined
}> = {}) => ({
days: [buildDay({ id: 1, day_number: 1 })],
assignments: {},
reservations: [],
accommodations: [],
optimizeFromAccommodation: false,
...over,
})
describe('buildDayRouteRuns', () => {
it('FE-MAP-DRP-001: orders a day\'s located stops into one run', () => {
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 1), at(45.76, 4.83, 0)] },
}))
expect(runs).toHaveLength(1)
// order_index, not the order they happen to sit in the array.
expect(runs[0].map(p => p.lat)).toEqual([45.76, 48.86])
expect(runs[0].every(p => p.isPlace)).toBe(true)
})
it('FE-MAP-DRP-002: drops a stop with no coordinates', () => {
const runs = buildDayRouteRuns(1, inputs({
assignments: {
'1': [
at(48.86, 2.35, 0),
buildAssignment({ day_id: 1, order_index: 1, place: buildPlace({ lat: null, lng: null }) }),
at(45.76, 4.83, 2),
],
},
}))
expect(runs[0]).toHaveLength(2)
})
it('FE-MAP-DRP-003: a lone stop is no drive at all', () => {
expect(buildDayRouteRuns(1, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
})
it('FE-MAP-DRP-004: carries the per-leg travel modes the router resolves against', () => {
const runs = buildDayRouteRuns(1, inputs({
assignments: {
'1': [
at(48.86, 2.35, 0, { leg_transport_mode: 'walking' }),
at(48.88, 2.36, 1, { incoming_leg_transport_mode: 'cycling' }),
],
},
}))
expect(runs[0][0].leg_transport_mode).toBe('walking')
expect(runs[0][1].incoming_leg_transport_mode).toBe('cycling')
})
it('FE-MAP-DRP-005: two real places stay one run however far apart they are', () => {
// Paris → Tokyo is far past MAX_DRIVE_KM, but both are real places someone
// planned, so the run stands: the reachability guard only ever splits a leg
// that touches a booking endpoint (#2133).
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 0), at(35.68, 139.69, 1)] },
}))
expect(runs).toHaveLength(1)
})
it('FE-MAP-DRP-006: bookends the day with its accommodation when the setting is on', () => {
const accommodation = {
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
start_day_id: 1, end_day_id: 1,
} as unknown as Accommodation
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
accommodations: [accommodation],
optimizeFromAccommodation: true,
}))
const flat = runs.flat()
expect(flat.some(p => p.lat === 48.80 && !p.isPlace)).toBe(true)
})
it('FE-MAP-DRP-007: leaves the hotel out when the setting is off', () => {
const accommodation = {
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
start_day_id: 1, end_day_id: 1,
} as unknown as Accommodation
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
accommodations: [accommodation],
optimizeFromAccommodation: false,
}))
expect(runs.flat().some(p => p.lat === 48.80)).toBe(false)
})
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
})
})
+169
View File
@@ -0,0 +1,169 @@
import { withHotelBookends } from './RouteCalculator'
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import { withinDriveRange } from '../../utils/geo'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
/** A waypoint on a day's drive, carrying the per-leg modes `resolveLegMode` reads off it. */
export interface DayRoutePoint {
lat: number
lng: number
isPlace: boolean
leg_transport_mode?: string | null
incoming_leg_transport_mode?: string | null
}
/** Everything the plan depends on, passed in rather than read from the store, so the
* builder stays pure and one caller can run it over every day of a trip at once. */
export interface DayRouteInputs {
days: Day[]
assignments: AssignmentsMap
reservations: Reservation[]
accommodations: Accommodation[]
optimizeFromAccommodation: boolean | undefined
}
/**
* The runs of waypoints a day's route is drawn from, hotel bookends folded in.
*
* Lifted out of `useRouteCalculation` unchanged so the trip-wide overview draws the
* same line the day view does. Every rule below is load-bearing and issue-numbered —
* a second copy of it would drift the moment either surface got the next fix.
*/
export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] {
const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input
const da = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const dayOrder = (id: number | null | undefined): number | null => {
if (id == null) return null
const d = allDays.find(x => x.id === id)
return d ? (d.day_number ?? allDays.indexOf(d)) : null
}
const thisOrder = dayOrder(dayId)
// The order the day plan shows is the order the map has to draw, so take it from
// the same place the plan does rather than rebuilding it here. getTransportForDay
// brings the span filter, the hotel/assignment exclusions and the leg expansion with
// it; getMergedItems places each leg by its own saved position, falling back to its
// time. Notes carry no coordinates, so none are passed.
const dayTransports = thisOrder == null ? [] : getTransportForDay({
reservations: allReservations.filter(r => TRANSPORT_TYPES.includes(r.type)),
dayId,
dayAssignmentIds: da.map(a => a.id),
days: allDays,
})
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
type Entry =
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
const entries: Entry[] = merged.flatMap((item): Entry[] => {
if (item.type === 'place') {
const a = item.data
if (!a.place?.lat || !a.place?.lng) return []
return [{
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
// Per-segment travel mode (#1281): mode of the leg leaving this place.
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
incoming: (a as { incoming_leg_transport_mode?: string | null }).incoming_leg_transport_mode ?? null,
}]
}
if (item.type !== 'transport') return []
const { from, to } = getTransportRouteEndpoints(item.data, dayId)
return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }]
})
// Group located places into driving runs. A transport WITH a location anchors the
// route to its departure point then breaks the run (you don't drive the flight); its
// arrival starts the next. A transport WITHOUT a location is ignored entirely. A run
// is only a real drive when it contains at least one actual place, or two back-to-back
// transports pair into a phantom [airport → airport] road route (#1394). A booking
// endpoint also has to be REACHABLE from the stop before it, or a long-haul arrival
// sorted in among the day's local stops gets stapled to one of them (#2133).
const runs: DayRoutePoint[][] = []
let currentRun: DayRoutePoint[] = []
let runHasPlace = false
const closeRun = () => {
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
currentRun = []
runHasPlace = false
}
for (const entry of entries) {
if (entry.kind === 'place') {
const prev = currentRun[currentRun.length - 1]
// The open run may be nothing but a far-away arrival endpoint — break rather
// than draw the ocean.
if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun()
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
runHasPlace = true
} else if (entry.from || entry.to) {
const prev = currentRun[currentRun.length - 1]
if (entry.from && (!prev || withinDriveRange(prev, entry.from))) currentRun.push({ ...entry.from, isPlace: false })
closeRun()
if (entry.to) currentRun.push({ ...entry.to, isPlace: false })
}
}
closeRun()
// Bookend the route with the day's accommodation, so the drawn line matches the
// sidebar's hotel legs.
const day = allDays.find(d => d.id === dayId)
const bookends = day && optimizeFromAccommodation !== false
? getDayBookendHotels(day, allDays, accommodations)
: null
const flatPts: DayRoutePoint[] = []
for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
}
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
// falls through to the day default for hotel-adjacent legs unless the place endpoint
// carries its own override.
const hotelPt = (a?: Accommodation): DayRoutePoint | null =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
// an arrival/departure day (#1321, #2133) all draw no bookend.
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
const firstStop = entries.find(contributes)
const lastStop = [...entries].reverse().find(contributes)
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
if (!e) return undefined
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
const role: CarrierEdge = side === 'first'
? (e.from ? 'departure' : 'arrival')
: (e.to ? 'arrival' : 'departure')
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
}
// A carrier with a located endpoint today means the day changes geography by
// air/rail/sea — the check-in hotel is then the day's destination and the
// check-out hotel its origin, which the no-time bookend default respects (#2157).
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
const firstWay = flatPts[0]
const lastWay = flatPts[flatPts.length - 1]
const morningHotel = hotelPt(bookends?.morning)
const eveningHotel = hotelPt(bookends?.evening)
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
const runsWithHotel: DayRoutePoint[][] = withHotelBookends(
runs,
firstWay,
lastWay,
drawMorning ? morningHotel : null,
drawEvening ? eveningHotel : null,
)
// Transfer day with no activities: you check out of one accommodation and into
// another, so there are no waypoints for withHotelBookends to attach a leg to.
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
const m = hotelPt(bookends?.morning)
const e = hotelPt(bookends?.evening)
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
}
return runsWithHotel
}
@@ -0,0 +1,10 @@
/**
* The drop shadow every floating map control carries.
*
* Tighter and darker than `--sidebar-shadow`, which is built for a panel resting on
* the app background: over map tiles its 0.10 alpha spread over 32px all but
* disappears, so a round control read as painted onto the map rather than floating
* above it. This is the locate button's shadow, which was always the odd one out for
* exactly that reason — the others were the ones that needed to match it.
*/
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
@@ -613,6 +613,26 @@ describe('ReservationMapboxOverlay declutter floors', () => {
attach(wide, labelled).update([hop(2, 'cruise', 0.4)], labelled)
expect(glMarkers.map(m => m.el.textContent)).toEqual(['GOA', 'PMI'])
})
it('FE-COMP-RESGL-043: a routed drive whose ends project close together still draws (#2275)', () => {
// 50 px between the ends, under the 80 px car floor; the road loops out 300 px.
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
const bare = freshMap()
attach(bare).update([hop(7, 'car', 0.05)], opts)
expect(features(bare)).toHaveLength(0)
const routed = freshMap()
attach(routed).update([hop(7, 'car', 0.05)], opts, new Map([[7, road]]))
expect(features(routed)).toHaveLength(1)
expect(features(routed)[0].geometry.coordinates).toEqual(road.map(([lat, lng]) => [lng, lat]))
})
it('FE-COMP-RESGL-044: a routed drive that really is tiny on screen stays decluttered', () => {
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
const map = freshMap()
attach(map).update([hop(7, 'car', 0.03)], opts, new Map([[7, road]]))
expect(features(map)).toHaveLength(0)
})
})
describe('ReservationMapboxOverlay waypoint edge cases', () => {
+16 -14
View File
@@ -13,6 +13,7 @@ import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, Tram
import { getTransitMapSegments } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
import { cleanEndpointName } from './reservationName'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types'
@@ -259,27 +260,31 @@ export class ReservationMapboxOverlay {
})
}
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
private linesFor(item: TransportItem): [number, number][][] {
const road = this.roadRoutes.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
}
private render() {
const map = this.map
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
const show = this.opts.showConnections
// Visible filter: require the on-screen pixel distance between
// endpoints to exceed a type-specific minimum, same as the Leaflet
// overlay, so tiny no-op transport lines don't clutter the map.
// Visible filter: a hop draws once what it will draw is long enough on
// screen to be worth it, same as the Leaflet overlay, so tiny no-op
// transport lines don't clutter the map. Measured along the drawn line,
// not between the ends: a routed drive can cover half the screen while
// its endpoints sit close together (#2275).
const project = (p: readonly [number, number]) => map.project([p[1], p[0]])
const visibleItems = show ? this.items.filter(item => {
try {
// A transit journey draws its real alignment, not a straight from->to line, so
// don't let the endpoint-proximity declutter hide it when the map is zoomed out
// (#1570). Mirrors the Leaflet overlay.
if (item.type === 'transit' && getTransitMapSegments(item.res).length > 0) return true
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return dist >= minPx
return hopIsVisible(item.type, this.linesFor(item), project)
} catch { return true }
}) : []
@@ -293,8 +298,7 @@ export class ReservationMapboxOverlay {
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
if (dist >= minPx) labelVisibleIds.add(item.res.id)
if (dist >= labelFloorPx(item.type)) labelVisibleIds.add(item.res.id)
} catch { /* ignore */ }
}
}
@@ -320,9 +324,7 @@ export class ReservationMapboxOverlay {
}))
}
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
const road = this.roadRoutes.get(item.res.id)
const lines = road && road.length >= 2 ? [road] : item.arcs
return lines.map(seg => ({
return this.linesFor(item).map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { countRoute, flushRouteUsage } from './routeUsageCounter'
import { routeUsageApi } from '../../api/routeUsage'
/**
* FE-MAP-ROUTECOUNT-001..006 — the tally behind "how much routing does an instance do".
*
* Two things are worth pinning: that it batches rather than posting per route, because a
* counter that costs a request of its own measures the load by adding to it, and that it
* never throws — it sits on the hot path of every route the map draws.
*/
const sample = {
profile: 'driving' as const,
surface: 'legs' as const,
selfHosted: false,
waypoints: 3,
km: 100,
failed: false,
}
describe('routeUsageCounter', () => {
beforeEach(() => {
vi.spyOn(routeUsageApi, 'report').mockResolvedValue(undefined)
vi.useFakeTimers()
})
afterEach(() => {
flushRouteUsage()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('FE-MAP-ROUTECOUNT-001: a single route posts nothing on its own', () => {
countRoute(sample)
expect(routeUsageApi.report).not.toHaveBeenCalled()
})
it('FE-MAP-ROUTECOUNT-002: the tally goes out once the timer runs out', () => {
countRoute(sample)
vi.advanceTimersByTime(60_000)
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries).toEqual([{ ...sample, requests: 1, failed: 0 }])
})
it('FE-MAP-ROUTECOUNT-003: routes of one kind add onto one entry', () => {
countRoute(sample)
countRoute({ ...sample, waypoints: 5, km: 200 })
countRoute({ ...sample, failed: true })
flushRouteUsage()
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({ requests: 3, waypoints: 11, km: 400, failed: 1 })
})
it('FE-MAP-ROUTECOUNT-004: a different kind or engine is its own entry', () => {
countRoute(sample)
countRoute({ ...sample, surface: 'alternatives' })
countRoute({ ...sample, selfHosted: true })
countRoute({ ...sample, profile: 'walking' })
flushRouteUsage()
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries).toHaveLength(4)
})
it('FE-MAP-ROUTECOUNT-005: a busy stretch flushes on its own, without waiting', () => {
for (let i = 0; i < 25; i++) countRoute(sample)
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries[0].requests).toBe(25)
})
it('FE-MAP-ROUTECOUNT-006: a refused report is swallowed, and the next tally starts clean', async () => {
vi.mocked(routeUsageApi.report).mockRejectedValueOnce(new Error('offline'))
countRoute(sample)
expect(() => flushRouteUsage()).not.toThrow()
await Promise.resolve()
countRoute(sample)
flushRouteUsage()
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[1]
expect(entries[0].requests).toBe(1)
})
})
@@ -0,0 +1,102 @@
import type { RouteUsageEntry, RouteUsageProfile, RouteUsageSurface } from '@trek/shared'
import { routeUsageApi } from '../../api/routeUsage'
/**
* Counts the routing this browser asks for, and posts the totals in batches.
*
* All routing happens client-side against an external engine, so the server never
* sees a route request and cannot count one. Without a count nobody knows what a
* self-hosted router would have to carry — how many requests a real trip costs, how
* big they are, how much already goes to an operator's own engine.
*
* Counters only. What goes over the wire is four integers per bucket: how many
* requests, how many waypoints, how many kilometres, how many failed. No coordinate,
* no route, no trip, nothing that says where anybody went.
*
* Batched on purpose. A counter that cost a request of its own would measure the load
* by adding to it, so the tally is held in memory and flushed on a timer, when it
* grows past a threshold, and when the page goes away.
*/
/** Flush when the tally has this many requests in it, whichever comes first. */
const FLUSH_AFTER_REQUESTS = 25
/** …or after this long, so a quiet session still reports what it did. */
const FLUSH_AFTER_MS = 60_000
/** Guard against a pathological session: drop the tally rather than grow forever. */
const MAX_BUCKETS = 40
type Bucket = Omit<RouteUsageEntry, 'profile' | 'surface' | 'selfHosted'> & {
profile: RouteUsageProfile
surface: RouteUsageSurface
selfHosted: boolean
}
const buckets = new Map<string, Bucket>()
let pending = 0
let timer: ReturnType<typeof setTimeout> | null = null
let unloadWired = false
function flush(): void {
if (timer !== null) { clearTimeout(timer); timer = null }
if (!buckets.size) return
const entries = [...buckets.values()]
buckets.clear()
pending = 0
// Fire and forget: a failed report is a lost count, not a problem worth telling
// anybody about. The next flush carries on from an empty tally either way.
void routeUsageApi.report(entries).catch(() => {})
}
function wireUnload(): void {
if (unloadWired || typeof document === 'undefined') return
unloadWired = true
// `visibilitychange`, not `unload`: the latter no longer fires reliably on mobile,
// where a session ends by switching apps rather than by closing a tab.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush()
})
}
/**
* Records one routing request. Never throws and never awaits: it sits on the hot path
* of every route the map draws.
*/
export function countRoute(sample: {
profile: RouteUsageProfile
surface: RouteUsageSurface
selfHosted: boolean
waypoints: number
km: number
failed: boolean
}): void {
try {
wireUnload()
const key = `${sample.profile}|${sample.surface}|${sample.selfHosted}`
const bucket = buckets.get(key)
if (bucket) {
bucket.requests += 1
bucket.waypoints += sample.waypoints
bucket.km += sample.km
if (sample.failed) bucket.failed += 1
} else {
if (buckets.size >= MAX_BUCKETS) return
buckets.set(key, {
profile: sample.profile,
surface: sample.surface,
selfHosted: sample.selfHosted,
requests: 1,
waypoints: sample.waypoints,
km: sample.km,
failed: sample.failed ? 1 : 0,
})
}
pending += 1
if (pending >= FLUSH_AFTER_REQUESTS) { flush(); return }
if (timer === null) timer = setTimeout(flush, FLUSH_AFTER_MS)
} catch { /* counting must never break routing */ }
}
/** Flushes now. Exported for tests and for a deliberate "report before you go". */
export function flushRouteUsage(): void {
flush()
}
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useMergedMapPois } from './useMergedMapPois'
import type { Poi } from './poiCategories'
/**
* FE-MAP-MERGEPOI-001..006 — the two searches that can run at once in road trip mode.
*
* The corridor answers "what is along the drive", the category pill answers "what is in
* view". Both are drawn, the corridor's copy of a shared hit wins, and the reference is
* only new when the content really is — the map rebuilds every pin off it.
*/
const poi = (osm_id: string, name = osm_id): Poi => ({
osm_id, name, lat: 48, lng: 2, category: 'hotel', source: 'openstreetmap',
} as Poi)
describe('useMergedMapPois', () => {
it('FE-MAP-MERGEPOI-001: outside road trip mode only the pill hits are drawn', () => {
const explore = [poi('n1')]
const { result } = renderHook(() => useMergedMapPois(null, explore))
expect(result.current).toBe(explore)
})
it('FE-MAP-MERGEPOI-002: with nothing found in view the corridor list passes through as-is', () => {
const corridor = [poi('n1')]
const { result } = renderHook(() => useMergedMapPois(corridor, []))
expect(result.current).toBe(corridor)
})
it('FE-MAP-MERGEPOI-003: before a corridor search the pill hits pass through as-is', () => {
const explore = [poi('n2')]
const { result } = renderHook(() => useMergedMapPois([], explore))
expect(result.current).toBe(explore)
})
it('FE-MAP-MERGEPOI-004: both searches draw together, corridor first', () => {
const { result } = renderHook(() => useMergedMapPois([poi('n1')], [poi('n2')]))
expect(result.current.map(p => p.osm_id)).toEqual(['n1', 'n2'])
})
it('FE-MAP-MERGEPOI-005: a place both searches found is drawn once, as the corridor knows it', () => {
// The corridor copy carries how far along the drive it sits, which the pill's does not.
const corridor = [poi('n1', 'Hotel am Weg')]
const { result } = renderHook(() => useMergedMapPois(corridor, [poi('n1', 'Hotel am Weg (Sicht)')]))
expect(result.current).toBe(corridor)
expect(result.current).toHaveLength(1)
expect(result.current[0].name).toBe('Hotel am Weg')
})
it('FE-MAP-MERGEPOI-006: an unchanged pair keeps its reference, so no pin is rebuilt', () => {
const corridor = [poi('n1')]
const explore = [poi('n2')]
const { result, rerender } = renderHook(() => useMergedMapPois(corridor, explore))
const first = result.current
rerender()
expect(result.current).toBe(first)
})
})
describe('useMergedMapPois — the refuel offers', () => {
const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi)
it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => {
// In road trip mode this is the only channel a POI reaches the map through, so
// without it somebody is asked to accept a stop they cannot see.
const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')]))
expect(result.current.map(p => p.osm_id)).toEqual(['offer'])
})
it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => {
const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')]))
expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b'])
})
it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => {
const corridor = [poi('a')]
const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), {
initialProps: { o: [] as Poi[] },
})
const first = result.current
rerender({ o: [] })
expect(result.current).toBe(first)
expect(result.current).toBe(corridor)
})
})
@@ -0,0 +1,42 @@
import { useMemo } from 'react'
import type { Poi } from './poiCategories'
/**
* The findings the map should draw, when two searches can be running at once.
*
* In road trip mode the corridor search answers "what is along this drive". The category
* pill answers a different question — "what is in view right now" — and a hotel at tonight's
* stop is exactly the case where someone wants the second one without giving up the first,
* so the two lists are drawn together rather than one replacing the other.
*
* Identity is `osm_id`, the same key the corridor search dedupes its own overlapping tiles
* on: a hotel found both along the route and in the viewport is one pin, and the corridor's
* copy wins because it carries how far along the drive it sits.
*
* `offered` is the third case: the handful of filling stations proposed where a tank runs
* out. They belong on the map for a plain reason — somebody is being asked to accept a
* stop, and a stop you cannot see is not one you can judge — but they are not a search
* result and must not survive the panel being closed, which is why they arrive separately
* rather than being folded into the corridor's own list.
*
* Returns the input array itself whenever there is nothing to merge. The map redraws off
* this reference, so handing it a fresh array on every render would rebuild every pin.
*/
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] {
return useMemo(() => {
const base = corridor === null ? explore : merge(corridor, explore)
return merge(base, offered)
}, [corridor, explore, offered])
}
/** Nothing offered, as one stable array, so the default never changes identity. */
const NONE: Poi[] = []
/** `a` with everything from `b` that it does not already carry, by `osm_id`. */
function merge(a: Poi[], b: Poi[]): Poi[] {
if (!b.length) return a
if (!a.length) return b
const seen = new Set(a.map(p => p.osm_id))
const extra = b.filter(p => !seen.has(p.osm_id))
return extra.length ? [...a, ...extra] : a
}
@@ -0,0 +1,139 @@
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { dayColor } from '../Roadtrip/dayColors'
import { useTripRouteOverview } from './useTripRouteOverview'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { AssignmentsMap, RouteSegment } from '../../types'
vi.mock('./RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('./RouteCalculator')>()
return { ...actual, calculateRouteWithLegs: vi.fn() }
})
const { calculateRouteWithLegs } = await import('./RouteCalculator')
const leg = (distance: number, duration: number): RouteSegment => ({
mid: [0, 0], from: [0, 0], to: [0, 0],
distance, duration,
distanceText: `${Math.round(distance / 1000)} km`,
durationText: '1 h', walkingText: '5 h', drivingText: '1 h',
})
const at = (lat: number, lng: number, order: number, dayId: number, extra: Record<string, unknown> = {}) =>
buildAssignment({ day_id: dayId, order_index: order, place: buildPlace({ lat, lng }), ...extra })
const DAYS = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 })]
const ASSIGNMENTS: AssignmentsMap = {
'1': [at(48.86, 2.35, 0, 1), at(48.88, 2.36, 1, 1)],
'2': [at(45.76, 4.83, 0, 2), at(45.78, 4.85, 1, 2)],
}
const render = (enabled = true, assignments: AssignmentsMap = ASSIGNMENTS, days = DAYS) =>
renderHook(
({ assignments: a }: { assignments: AssignmentsMap }) =>
useTripRouteOverview(7, days, a, [], [], 'driving', enabled),
{ initialProps: { assignments } },
)
beforeEach(() => {
vi.mocked(calculateRouteWithLegs).mockReset()
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
distance: 12000, duration: 900,
legs: [leg(12000, 900)],
})
})
describe('useTripRouteOverview', () => {
it('FE-MAP-TRO-001: does nothing at all until it is switched on', () => {
const { result } = render(false)
expect(result.current.days).toEqual([])
expect(result.current.totalDistance).toBe(0)
expect(result.current.loading).toBe(false)
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
it('FE-MAP-TRO-002: sums every day into the trip total', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days).toHaveLength(2)
expect(result.current.days.map(d => d.distance)).toEqual([12000, 12000])
expect(result.current.totalDistance).toBe(24000)
expect(result.current.totalDuration).toBe(1800)
})
it('FE-MAP-TRO-003: gives each day the colour the road trip draws it in', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
const colors = result.current.days.map(d => d.color)
// The same palette either way, so a day does not change colour when the trip is
// read as a road trip instead of an overview.
expect(colors[0]).toEqual(dayColor(1))
expect(colors[1]).toEqual(dayColor(2))
// Every drawn line is labelled with the colour of the day it belongs to.
expect(result.current.lineColors).toHaveLength(result.current.lines.length)
expect(new Set(result.current.lineColors.map(c => c.line)).size).toBe(2)
})
it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => {
const { result } = render(true, {
'1': [at(48.86, 2.35, 0, 1, { leg_transport_mode: 'walking' }), at(48.88, 2.36, 1, 1)],
'2': ASSIGNMENTS['2'],
})
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[0].modes).toEqual(['walking'])
expect(result.current.days[1].modes).toEqual(['driving'])
expect(result.current.days[0].segments[0].mode).toBe('walking')
})
it('FE-MAP-TRO-005: draws straight lines first and keeps them when routing refuses', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new Error('429'))
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
// The shape of the trip is still on the map...
expect(result.current.lines).toHaveLength(2)
expect(result.current.lines[0]).toEqual([[48.86, 2.35], [48.88, 2.36]])
// ...but a leg that never answered contributes no distance.
expect(result.current.totalDistance).toBe(0)
})
it('FE-MAP-TRO-006: a day with nothing to drive is left out', async () => {
const { result } = render(true, { '1': ASSIGNMENTS['1'], '2': [at(45.76, 4.83, 0, 2)] })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days.map(d => d.dayId)).toEqual([1])
})
it('FE-MAP-TRO-007: frames the whole trip, not one day of it', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.focusPoints.length).toBe(result.current.lines.flat().length)
expect(result.current.focusPoints.length).toBeGreaterThan(2)
})
it('FE-MAP-TRO-008: renaming a place does not re-route the trip', async () => {
const { result, rerender } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
const calls = vi.mocked(calculateRouteWithLegs).mock.calls.length
expect(calls).toBeGreaterThan(0)
// A new object identity with the same geometry: the plan memo re-runs, the
// routing round must not.
rerender({
assignments: {
...ASSIGNMENTS,
'1': ASSIGNMENTS['1'].map(a => ({ ...a, place: { ...a.place, name: 'Renamed' } })),
},
})
await waitFor(() => expect(result.current.loading).toBe(false))
expect(vi.mocked(calculateRouteWithLegs).mock.calls.length).toBe(calls)
})
})
@@ -0,0 +1,227 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan'
import { resolveLegMode } from '../Planner/legMode'
import { useSettingsStore } from '../../store/settingsStore'
import { dayColor } from '../Roadtrip/dayColors'
import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types'
/** One travel day of the overview: the roads it covers, in its own colour. */
export interface TripOverviewDay {
dayId: number
dayNumber: number
date: string | null
title: string | null
/** The core and casing this day is drawn in — the road trip's palette, so a day is
* the same colour whichever way the trip is being read. */
color: { line: string; casing: string }
/** One polyline per run of the day, `[lat, lng]`. */
lines: [number, number][][]
segments: RouteSegment[]
/** Metres and seconds, summed over the day's legs. */
distance: number
duration: number
/** The modes this day is actually travelled in, first use first. */
modes: string[]
}
export interface TripRouteOverview {
days: TripOverviewDay[]
/** Every day's polylines flattened in trip order — what the map draws. */
lines: [number, number][][]
/** The colour of each entry of `lines`, same index. */
lineColors: { line: string; casing: string }[]
segments: RouteSegment[]
/** Every drawn coordinate, so the map can frame the whole trip at once. */
focusPoints: [number, number][]
totalDistance: number
totalDuration: number
/** True until every leg has answered — the totals are a partial sum until then. */
loading: boolean
}
const EMPTY: TripRouteOverview = {
days: [], lines: [], lineColors: [], segments: [], focusPoints: [],
totalDistance: 0, totalDuration: 0, loading: false,
}
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
* exactly as the single-day route builds them. */
interface Chunk { points: DayRoutePoint[]; mode: string }
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] {
const chunks: Chunk[] = []
let i = 0
while (i < run.length - 1) {
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
let end = i + 1
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
chunks.push({ points: run.slice(i, end + 1), mode })
i = end
}
return chunks
}
const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng])
/**
* Every travel day's route at once, each day in its own colour, with the trip's total
* distance (#1736).
*
* The geometry is the day view's own — `buildDayRouteRuns` is the same builder
* `useRouteCalculation` draws the selected day from, so a day looks identical whether
* you are looking at it alone or at the whole trip. Only the aggregation is new.
*
* Legs go out a few at a time rather than all at once: a fortnight is dozens of legs and
* the routing host is usually a shared OSRM. RouteCalculator's cache is shared with the
* day route and the sidebar connectors, so the day already on screen costs nothing.
*/
export function useTripRouteOverview(
tripId: number | null,
days: Day[],
assignments: AssignmentsMap,
reservations: Reservation[],
accommodations: Accommodation[],
profile: RouteProfileKey,
enabled: boolean,
): TripRouteOverview {
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
// Leg text is formatted at compute time, so a km↔mi switch has to re-run (#1300).
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [result, setResult] = useState<TripRouteOverview>(EMPTY)
const abortRef = useRef<AbortController | null>(null)
const plan = useMemo(() => {
if (!enabled) return []
return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(day => ({
day,
runs: buildDayRouteRuns(day.id, { days, assignments, reservations, accommodations, optimizeFromAccommodation }),
}))
.filter(entry => entry.runs.length > 0)
}, [enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation])
// Only geometry and mode decide whether legs have to be fetched again: renaming a
// place or editing its notes must not fire a routing round.
const planKey = useMemo(
() => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
.map(run => run.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)},${p.leg_transport_mode ?? ''},${p.incoming_leg_transport_mode ?? ''}`).join('|'))
.join('/')}`).join(';'),
[plan],
)
useEffect(() => {
abortRef.current?.abort()
if (!plan.length) { setResult(EMPTY); return }
const controller = new AbortController()
abortRef.current = controller
const colorFor = (day: Day) => dayColor(day.day_number ?? 0)
// Chunk every day up front, so the routed answers can be filed by position and
// reassembled in order however they come back.
const chunked = plan.map(({ day, runs }) => {
const dayDefaultMode = day.default_transport_mode || profile
return { day, runs: runs.map(run => chunkRun(run, dayDefaultMode)) }
})
const assemble = (
routed: (({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)[][][],
): TripRouteOverview => {
const overviewDays: TripOverviewDay[] = chunked.map(({ day, runs }, d) => {
const lines: [number, number][][] = []
const segments: RouteSegment[] = []
runs.forEach((chunks, r) => {
const polyline: [number, number][] = []
chunks.forEach((chunk, c) => {
const answer = routed[d][r][c]
const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points)
for (const point of coords) {
// Drop the point shared with the previous chunk so concatenated legs
// don't leave a duplicate at each junction.
const last = polyline[polyline.length - 1]
if (last && last[0] === point[0] && last[1] === point[1]) continue
polyline.push(point)
}
if (answer) for (const leg of answer.legs) segments.push({ ...leg, mode: chunk.mode })
})
if (polyline.length >= 2) lines.push(polyline)
})
const modes: string[] = []
for (const chunk of runs.flat()) if (!modes.includes(chunk.mode)) modes.push(chunk.mode)
return {
dayId: day.id,
dayNumber: day.day_number ?? 0,
date: day.date ?? null,
title: day.title ?? null,
color: colorFor(day),
lines,
segments,
distance: segments.reduce((sum, s) => sum + s.distance, 0),
duration: segments.reduce((sum, s) => sum + s.duration, 0),
modes,
}
})
const lines = overviewDays.flatMap(d => d.lines)
return {
days: overviewDays,
lines,
lineColors: overviewDays.flatMap(d => d.lines.map(() => d.color)),
segments: overviewDays.flatMap(d => d.segments),
focusPoints: lines.flat(),
totalDistance: overviewDays.reduce((sum, d) => sum + d.distance, 0),
totalDuration: overviewDays.reduce((sum, d) => sum + d.duration, 0),
loading: false,
}
}
const routed = chunked.map(({ runs }) => runs.map(chunks => chunks.map(() => null as
({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)))
// Straight lines first so the shape of the trip is on screen immediately, then the
// real roads replace them — the same two-step the single-day route draws with.
setResult({ ...assemble(routed), loading: true })
;(async () => {
const tasks: (() => Promise<void>)[] = []
chunked.forEach(({ day, runs }, d) => {
runs.forEach((chunks, r) => {
chunks.forEach((chunk, c) => {
tasks.push(async () => {
try {
const answer = await calculateRouteWithLegs(
chunk.points.map(p => ({ lat: p.lat, lng: p.lng })),
{ signal: controller.signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
} catch {
// Routing refused this leg (usually a rate limit) — it keeps its
// straight line and contributes no distance, exactly as a day route's
// failed leg does.
}
})
})
})
})
// Small pool on purpose, the same one the sidebar connectors use: a fortnight is
// dozens of legs and the routing host is often a shared OSRM.
let next = 0
const worker = async () => {
while (next < tasks.length && !controller.signal.aborted) await tasks[next++]()
}
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker))
if (!controller.signal.aborted) setResult(assemble(routed))
})()
return () => controller.abort()
// planKey is derived from the same inputs as plan, so keying on the string is
// equivalent while staying stable across unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [planKey, profile, tripId, distanceUnit])
return result
}
@@ -0,0 +1,367 @@
import { describe, it, expect, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore'
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute'
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
/**
* A real polyline6, encoded from three known points and verified by decoding it back.
* Precision matters here more than anywhere else in the file: read as polyline5 the
* first vertex would be 535.511, which is not a latitude, and a wrong guess draws a
* line into the sea rather than failing.
*/
const SHAPE = 'w~nceBg}}`RgkPwjr@~fn~@oirmE'
const SHAPE_POINTS: [number, number][] = [[53.5511, 9.9937], [53.56, 10.02], [52.52, 13.405]]
const answer = (summary: Record<string, unknown> = {}) => ({
trip: {
legs: [{ shape: SHAPE }],
summary: {
has_time_restrictions: false,
has_toll: false,
has_highway: true,
has_ferry: false,
// Asked in kilometres, so this is 295.7 km and 191 minutes — the real
// Hamburg-to-Berlin answer, which the adapter has to hand on in metres and seconds.
length: 295.7,
time: 11460,
...summary,
},
},
})
const setSettings = (patch: Record<string, string>) =>
useSettingsStore.setState(st => ({ settings: { ...st.settings, ...patch } }))
afterEach(() => {
setSettings({ routing_base_url: '', valhalla_base_url: '' })
})
const HAMBURG = { lat: 53.5511, lng: 9.9937 }
const BERLIN = { lat: 52.52, lng: 13.405 }
describe('valhallaBase', () => {
it('VALHALLA-BASE-001: falls back to the public instance when nothing is configured', () => {
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
expect(valhallaAvailable()).toBe(true)
})
it('VALHALLA-BASE-002: uses the configured instance and drops its trailing slashes', () => {
setSettings({ valhalla_base_url: 'https://valhalla.example.org//' })
expect(valhallaBase()).toBe('https://valhalla.example.org')
})
it('VALHALLA-BASE-003: asks nothing when the instance runs its own OSRM', () => {
// An operator who filled in their own router chose against the public hosts, usually
// because the instance is not supposed to reach third parties at all. Adding one they
// never asked for would undo that, and their MLD-built OSRM answers exclude itself.
setSettings({ routing_base_url: 'https://osrm.example.org' })
expect(valhallaBase()).toBeNull()
expect(valhallaAvailable()).toBe(false)
})
it('VALHALLA-BASE-004: an own Valhalla wins over an own OSRM', () => {
setSettings({ routing_base_url: 'https://osrm.example.org', valhalla_base_url: 'https://v.example.org' })
expect(valhallaBase()).toBe('https://v.example.org')
})
})
describe('runFrom', () => {
const total = (data: unknown) => runFrom(data)?.total ?? null
it('VALHALLA-PARSE-001: decodes the shape as polyline6 and converts km to metres', () => {
const leg = total(answer())
expect(leg).not.toBeNull()
expect(leg!.coordinates).toEqual(SHAPE_POINTS)
expect(leg!.distance).toBeCloseTo(295700, 0)
expect(leg!.duration).toBe(11460)
})
it('VALHALLA-PARSE-002: carries the flags through as booleans', () => {
const leg = total(answer({ has_toll: true, has_ferry: true, has_highway: false }))
expect(leg).toMatchObject({ hasToll: true, hasFerry: true, hasHighway: false })
})
it('VALHALLA-PARSE-003: a missing flag reads as absent rather than unknown', () => {
// The public instance omits a flag it has nothing to say about, and the caller turns
// this into a label. Undefined has to become false, not a truthy object.
const leg = total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 1, time: 60 } } })
expect(leg).toMatchObject({ hasToll: false, hasHighway: false, hasFerry: false })
})
it('VALHALLA-PARSE-004: keeps a chain divided leg by leg, and adds it up as well', () => {
// What a request with a via really answers: one summary per pair of stops. The
// schedule is built from those, so flattening them into a total would lose the
// arrival time at every stop but the last.
const chain = {
trip: {
legs: [
{ shape: SHAPE, summary: { length: 100, time: 3600, has_toll: false, has_highway: true, has_ferry: false } },
{ shape: SHAPE, summary: { length: 50, time: 1800, has_toll: true, has_highway: false, has_ferry: false } },
],
summary: { length: 150, time: 5400, has_toll: true, has_highway: true, has_ferry: false },
},
}
const run = runFrom(chain)!
expect(run.legs.map(l => l.distance)).toEqual([100000, 50000])
expect(run.legs.map(l => l.hasToll)).toEqual([false, true])
expect(run.total.distance).toBe(150000)
expect(run.total.duration).toBe(5400)
// One tolled leg makes the drive tolled. `every` here would call a day with a
// single toll booth in it toll-free.
expect(run.total.hasToll).toBe(true)
expect(run.total.coordinates).toHaveLength(SHAPE_POINTS.length * 2)
})
it('VALHALLA-PARSE-007: several legs with no summaries of their own are refused', () => {
// There is no honest way to divide one total between them, and a guess would reach
// the user as a wrong arrival time rather than as a missing one.
const two = { trip: { legs: [{ shape: SHAPE }, { shape: SHAPE }], summary: { length: 2, time: 120 } } }
expect(runFrom(two)).toBeNull()
})
it('VALHALLA-PARSE-005: an error body is not a leg', () => {
// What a 400 carries: no trip at all. Parsing it as one would produce a route of
// NaN kilometres rather than the null every caller reads as "no second way".
expect(total({ error_code: 442, error: 'No path could be found for input' })).toBeNull()
expect(total({ trip: { legs: [], summary: { length: 1, time: 1 } } })).toBeNull()
expect(total({ trip: { legs: [{ shape: SHAPE }] } })).toBeNull()
expect(total(null)).toBeNull()
})
it('VALHALLA-PARSE-006: a summary without numbers is not a leg', () => {
expect(total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 'far', time: 60 } } })).toBeNull()
})
})
describe('legAvoids', () => {
const leg = (flags: Partial<{ hasToll: boolean; hasHighway: boolean; hasFerry: boolean }>) => ({
coordinates: SHAPE_POINTS, distance: 1, duration: 1,
hasToll: false, hasHighway: false, hasFerry: false, ...flags,
})
it('VALHALLA-AVOID-001: reads the answer, not the request', () => {
// use_tolls: 0 is a weighting. Between two points with no untolled connection the
// answer is still a tolled road, honestly flagged, and labelling that "No tolls"
// would put a lie on the map.
expect(legAvoids(leg({ hasToll: false }), 'toll')).toBe(true)
expect(legAvoids(leg({ hasToll: true }), 'toll')).toBe(false)
})
it('VALHALLA-AVOID-002: each class reads its own flag', () => {
expect(legAvoids(leg({ hasHighway: true }), 'motorway')).toBe(false)
expect(legAvoids(leg({ hasHighway: true }), 'toll')).toBe(true)
expect(legAvoids(leg({ hasFerry: true }), 'ferry')).toBe(false)
expect(legAvoids(leg({ hasFerry: false }), 'ferry')).toBe(true)
})
})
describe('valhallaRouteAvoiding', () => {
it('VALHALLA-REQ-001: posts the avoidance as a costing option under the right profile', async () => {
let body: Record<string, unknown> | null = null
server.use(
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
body = await request.json() as Record<string, unknown>
return HttpResponse.json(answer())
})
)
const leg = await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
expect(leg!.distance).toBeCloseTo(295700, 0)
expect(body).toMatchObject({
costing: 'auto',
costing_options: { auto: { use_tolls: 0 } },
directions_options: { units: 'kilometers' },
})
// The radius is what makes a place in a pedestrian zone routable at all: without it
// Vienna's Stephansdom answers error 442 and the whole leg is lost.
expect(body!.locations).toEqual([
{ lat: 53.5511, lon: 9.9937, radius: 50 },
{ lat: 52.52, lon: 13.405, radius: 50 },
])
})
it('VALHALLA-REQ-002: each class maps to its own costing option', async () => {
const seen: Record<string, unknown>[] = []
server.use(
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
seen.push(await request.json() as Record<string, unknown>)
return HttpResponse.json(answer())
})
)
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'ferry')
expect(seen.map(b => b.costing_options)).toEqual([
{ auto: { use_highways: 0 } },
{ auto: { use_ferry: 0 } },
])
})
it('VALHALLA-REQ-003: walking and cycling get their own costing models', async () => {
const seen: string[] = []
server.use(
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { costing: string }
seen.push(body.costing)
return HttpResponse.json(answer())
})
)
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'walking', 'ferry')
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry')
expect(seen).toEqual(['pedestrian', 'bicycle'])
})
it('VALHALLA-REQ-004: a refusal is a null, not a throw', async () => {
// Every failure means the same thing to the caller — there is no second way to
// show — so a 400 for an unroutable point and a 429 for the rate limit both end
// here rather than as an error over a map that is drawing fine.
server.use(
http.post(FOSSGIS_VALHALLA, () =>
HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 })
)
)
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
})
it('VALHALLA-REQ-005: a host that is not a Valhalla is only asked once', async () => {
let calls = 0
setSettings({ valhalla_base_url: 'https://not-valhalla.example.org' })
server.use(
http.post('https://not-valhalla.example.org/route', () => {
calls++
return new HttpResponse(null, { status: 404 })
})
)
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
// A 404 is about the URL rather than about this leg, so remembering it saves every
// later leg a request against a host that will never answer.
expect(calls).toBe(1)
})
it('VALHALLA-REQ-006: asks nothing at all when the instance has its own OSRM', async () => {
let calls = 0
setSettings({ routing_base_url: 'https://osrm.example.org' })
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(answer()) }))
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
expect(calls).toBe(0)
})
})
describe('valhallaRun over a whole day', () => {
/** A chain answer with one summary per leg, which is what a real request returns. */
const chain = (legCount: number) => ({
trip: {
legs: Array.from({ length: legCount }, () => ({
shape: SHAPE,
summary: { length: 10, time: 600, has_toll: false, has_highway: true, has_ferry: false },
})),
summary: { length: 10 * legCount, time: 600 * legCount, has_toll: false, has_highway: true, has_ferry: false },
},
})
const stops = (n: number) =>
Array.from({ length: n }, (_, i) => ({ lat: 53 - i * 0.1, lng: 10 + i * 0.1 }))
it('VALHALLA-RUN-001: a day inside the cap goes out as one request, one leg per pair', async () => {
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
calls++
const body = await request.json() as { locations: unknown[] }
return HttpResponse.json(chain(body.locations.length - 1))
}))
const run = await valhallaRun(stops(6), 'driving', [])
expect(calls).toBe(1)
expect(run!.legs).toHaveLength(5)
expect(run!.total.distance).toBe(50000)
})
it('VALHALLA-RUN-002: a day over the cap is split on a shared point, and the legs still line up', async () => {
// Ten locations is hard: the eleventh answers error 150. Split runs overlap by one
// point, which is exact rather than approximate — measured over a six-stop day,
// [0..3] plus [3..5] came back within two metres of the same day asked whole.
const sizes: number[] = []
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
sizes.push(body.locations.length)
return HttpResponse.json(chain(body.locations.length - 1))
}))
const run = await valhallaRun(stops(14), 'driving', ['toll'])
// Fourteen stops: the first run takes ten, the second starts on the tenth again and
// takes the remaining five. Nine pairs plus four.
expect(sizes).toEqual([10, 5])
// Thirteen pairs across two requests, none lost and none counted twice.
expect(run!.legs).toHaveLength(13)
expect(run!.total.distance).toBe(130000)
})
it('VALHALLA-RUN-003: never sends more than ten locations, however long the day', async () => {
const sizes: number[] = []
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
sizes.push(body.locations.length)
return HttpResponse.json(chain(body.locations.length - 1))
}))
await valhallaRun(stops(28), 'driving', [])
expect(Math.max(...sizes)).toBeLessThanOrEqual(10)
expect(sizes.reduce((sum, n) => sum + n - 1, 0)).toBe(27)
})
it('VALHALLA-RUN-004: a piece that will not answer loses the whole day rather than half of it', async () => {
// Half a day of legs is worse than none. The schedule chains leg times blindly and
// cannot tell that some are missing, so it would print a complete, plausible,
// silently wrong timetable instead of leaving a visible gap.
//
// The SECOND CHUNK fails, not the second request: one attempt is retried, so failing
// by call number would just be answered on the retry.
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
if (body.locations.length < 10) {
return HttpResponse.json({ error_code: 442, error: 'No path' }, { status: 400 })
}
return HttpResponse.json(chain(body.locations.length - 1))
}))
await expect(valhallaRun(stops(14), 'driving', ['toll'])).resolves.toBeNull()
})
it('VALHALLA-RUN-007: a piece that fails once is asked again rather than losing the day', async () => {
// The caller retries the whole task, so without this a rate limit on the last piece
// of a split day throws away the pieces that already answered and asks for all of
// them again — three times over, against a host that allows one request a second.
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
calls++
if (calls === 2) return new HttpResponse(null, { status: 429 })
return HttpResponse.json(chain(body.locations.length - 1))
}))
const run = await valhallaRun(stops(14), 'driving', ['toll'])
expect(run!.legs).toHaveLength(13)
// First piece, second piece refused, second piece again.
expect(calls).toBe(3)
})
it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => {
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) }))
await expect(valhallaRun(stops(1), 'driving', [])).resolves.toBeNull()
expect(calls).toBe(0)
})
it('VALHALLA-RUN-006: an answer with the wrong number of legs is refused', async () => {
// A day whose legs do not match its stops cannot be assigned to the rail, and
// guessing which stop is missing would put the wrong time against the right place.
server.use(http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(chain(2))))
await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull()
})
})
+411
View File
@@ -0,0 +1,411 @@
import { useSettingsStore } from '../../store/settingsStore'
import { decodePolyline } from './transitGeometry'
import { haversineKm } from '../../utils/geo'
import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
/**
* Valhalla, the second routing engine, asked only the questions OSRM cannot answer.
*
* The shipped OSRM hosts are built without excludable road classes: their car profile
* says so in its own Lua ("no memory for classes on demand server"), and `exclude=`
* comes back as HTTP 400 on every leg. So the one thing a road tripper keeps asking
* for — drive this without the tolls — has never worked on a default install.
*
* FOSSGIS runs a public Valhalla on the whole planet next to it, keyless, and there
* avoidance is a runtime weighting rather than a profile that has to be rebuilt. This
* module is the adapter: same question, different engine, answer shaped like the OSRM
* one so the callers do not have to care which engine spoke.
*
* Deliberately NOT a replacement for the OSRM path. Three measured reasons:
* - it caps at ten locations, and following a track sends a day's stops plus up to
* nine anchors, so the bundled requests would start failing;
* - it refuses a point it cannot bind to a road, where OSRM still snaps one. The
* Stephansdom, in a pedestrian zone, answers `No path could be found for input`
* until a search radius is allowed;
* - it prices roads differently. Over ten European legs its drive times sat 5.4 %
* above OSRM's on average and 36 % above on one, and those times feed the arrival
* clocks of a road trip day. Mixing engines inside one chain would move times
* nobody asked to move.
*
* Which leaves exactly the shape this module serves: two points, one avoidance, asked
* on demand, drawn as an offer next to the route rather than replacing it.
*/
/** The public instance, matching how the OSRM defaults are shipped rather than configured. */
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de'
/**
* How far Valhalla may look for a road under a point, in metres.
*
* Zero is its default and it is the wrong default for TREK's data, which is full of
* museums, viewpoints and restaurants that sit inside a pedestrian zone. Measured on
* Vienna's Stephansdom: without a radius the whole route answers error 442, with 50 it
* routes, and 200 changed nothing further. So 50 buys the fix and nothing beyond it.
*/
const SNAP_RADIUS_M = 50
/** TREK's profile names against Valhalla's costing models. */
const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
driving: 'auto',
walking: 'pedestrian',
cycling: 'bicycle',
}
/**
* What a caller can ask to be left out, against the costing option that asks for it.
*
* Every one of these is a weighting from 0 to 1, not a ban — which is the whole reason
* this engine is worth having. Asked to avoid tolls between New York and Boston it
* still returns a tolled road, because there is no untolled way, where OSRM's `exclude`
* would refuse to answer at all. The caller has to read the answer to find out what it
* got, which is what `hasToll` and friends are for.
*/
const AVOID_OPTION: Record<AvoidClass, string> = {
motorway: 'use_highways',
toll: 'use_tolls',
ferry: 'use_ferry',
}
/** The same three classes the rest of the app names; aliased so this file reads on its own. */
export type AvoidClass = RouteAvoidClass
/**
* Locations one request may carry. The eleventh answers
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400.
*/
const MAX_LOCATIONS = 10
/**
* Pause between the pieces of a split day, in milliseconds.
*
* The public instance allows about one request a second per client, and a day long
* enough to be split is exactly the case that would fire two back to back. Matches the
* 1100 ms the road trip rail already leaves between its own days.
*/
const CHUNK_PAUSE_MS = 1100
/**
* Extra attempts for one piece of a split day.
*
* One, not a ladder: the common failure here is the public instance's rate limit, which
* a single pause clears, and anything a second attempt cannot fix is not going to be
* fixed by a fourth while somebody waits for their map.
*/
const CHUNK_RETRIES = 1
/** One way of driving a leg, in the units OSRM answers in so callers need no branch. */
export interface ValhallaLeg {
coordinates: [number, number][]
/** Metres. Valhalla is asked in kilometres and converted here. */
distance: number
/** Seconds. */
duration: number
/** What the route ACTUALLY has, as opposed to what was asked for. */
hasToll: boolean
hasHighway: boolean
hasFerry: boolean
}
/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */
export interface ValhallaRun {
legs: ValhallaLeg[]
total: ValhallaLeg
/**
* Where each waypoint actually met the road.
*
* Reconstructed from the shape rather than read from a field, because Valhalla echoes
* the coordinates it was GIVEN in `trip.locations` and never says where it bound them.
* The ends of a leg's line are those points: measured against Füssen, its first vertex
* came back at 47.571617 / 10.70153, which is OSRM's snapped location for the same
* request to the digit. Without this the dashed kerb-to-door spur from #2273 would
* quietly disappear from every day routed by this engine.
*/
snapped: SnappedWaypoint[]
}
/**
* The instance's Valhalla, or null when it should not ask one.
*
* Blank falls back to the public instance, the same reading `routing_base_url` gets —
* with one condition. An operator who filled in their OWN router chose deliberately
* against the public hosts, usually because their instance is not supposed to talk to
* third parties at all, and quietly adding a second public host they never asked for
* would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD
* pipeline answers `exclude` on its own, which is exactly the fallback below.
*/
export function valhallaBase(): string | null {
const settings = useSettingsStore.getState().settings
const configured = settings.valhalla_base_url?.trim()
if (configured) return configured.replace(/\/+$/, '')
return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA
}
/** True when this engine can be asked at all, so callers can skip building a request. */
export function valhallaAvailable(): boolean {
return valhallaBase() !== null
}
/** Hosts that have already proved they are not a Valhalla, by base URL. */
const notValhalla = new Set<string>()
/**
* One leg with a road class weighted away, or null when it cannot be had.
*
* Null for every failure, deliberately: this is an offer next to a route that already
* exists, so a host that is down, a rate limit, a point in a field and a genuinely
* connected-only-by-motorway leg all mean the same thing to the caller — there is no
* second way to show. The one failure worth remembering is a host that is not a
* Valhalla at all, because that is true of every leg from now on rather than this one.
*/
export async function valhallaRouteAvoiding(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
avoid: AvoidClass,
signal?: AbortSignal,
): Promise<ValhallaLeg | null> {
const run = await valhallaRun([from, to], profile, [avoid], signal)
return run ? run.total : null
}
/**
* A whole chain of waypoints, one leg per pair, or null when it cannot be had.
*
* Split when it has to be. Ten locations is a hard cap, and a road trip day is bundled
* into one request precisely so it appears at once rather than trickling in — so a day
* over the cap is cut into runs that overlap by one point and the legs are concatenated.
* That is exact, not an approximation: measured over a six-stop day, [0..3] plus [3..5]
* came back within two metres and under a second of the same day asked whole, because
* the shared point is routed to and from identically.
*
* The pieces go out one after another with a pause, since a day long enough to need
* splitting is exactly the one that would otherwise fire two requests inside a second
* at a host that allows one.
*/
export async function valhallaRun(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
): Promise<ValhallaRun | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
const legs: ValhallaLeg[] = []
const chunks = chunkWaypoints(waypoints)
for (const [index, chunk] of chunks.entries()) {
if (signal?.aborted) return null
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
// Retried per piece rather than per day. The caller retries the whole task, so a
// rate limit on the last piece of a split day would otherwise throw away the pieces
// that already answered and ask for all of them again — three times over, against a
// host that allows one request a second.
let run = await requestRun(base, chunk, profile, avoid, signal)
for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
// Not when the host just proved it is not a Valhalla at all: that answer will not
// change on a second ask, and asking anyway is the request the memo exists to save.
if (notValhalla.has(base)) break
await pause(CHUNK_PAUSE_MS, signal)
run = await requestRun(base, chunk, profile, avoid, signal)
}
// A day is one answer or none. Half a day of legs would be worse than no answer:
// the schedule chains leg times blindly and cannot tell that it is missing some, so
// it would print a complete, plausible, wrong timetable rather than a gap.
if (!run) return null
legs.push(...run.legs)
}
if (legs.length !== waypoints.length - 1) return null
return { legs, total: joinLegs(legs), snapped: snappedFrom(legs, waypoints) }
}
/**
* Where each waypoint met the road, taken off the ends of the legs that touch it.
*
* A leg's line starts at the bound position of its first stop and ends at the bound
* position of its second, so stop i is the head of leg i, and the last stop is the tail
* of the last leg. The distance is measured here rather than reported, the same recovery
* `readSnapped` already applies when OSRM leaves the field out.
*/
function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoint[] {
return waypoints.map((waypoint, i) => {
const line = i < legs.length ? legs[i].coordinates : legs[legs.length - 1].coordinates
const at = i < legs.length ? line[0] : line[line.length - 1]
const asked: [number, number] = [waypoint.lat, waypoint.lng]
return {
asked,
at,
meters: haversineKm(waypoint, { lat: at[0], lng: at[1] }) * 1000,
}
})
}
/** Runs of at most ten points, each starting on the one the previous ended at. */
function chunkWaypoints(waypoints: Waypoint[]): Waypoint[][] {
if (waypoints.length <= MAX_LOCATIONS) return [waypoints]
const chunks: Waypoint[][] = []
for (let start = 0; start < waypoints.length - 1; start += MAX_LOCATIONS - 1) {
chunks.push(waypoints.slice(start, start + MAX_LOCATIONS))
}
return chunks
}
function pause(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise(resolve => {
const timer = setTimeout(resolve, ms)
signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
})
}
/** One request, at most ten locations. */
async function requestRun(
base: string,
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const costing = COSTING[profile]
// All of them in one request: the options are independent weightings, so asking to
// leave out tolls and ferries together is one question rather than two routes to
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
// away together answered 656.9 km against 339.2 km for the plain route.
const options: Record<string, number> = {}
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
const body = {
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
costing,
costing_options: { [costing]: options },
directions_options: { units: 'kilometers' },
}
try {
const response = await fetch(`${base}/route`, {
method: 'POST',
// A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a
// client to identify itself with. It costs a preflight, which is answered.
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
body: JSON.stringify(body),
signal,
})
if (!response.ok) {
// 404 and 405 mean this URL is not a Valhalla — an OSRM pointed at by mistake,
// or a proxy that swallowed the path. Every other status is about this request:
// 400 carries the real refusals (unroutable point, over 1500 km apart) and 429
// is the public instance's one-per-second.
if (response.status === 404 || response.status === 405) notValhalla.add(base)
return null
}
return runFrom(await response.json())
} catch {
return null
}
}
/**
* Several legs read as one, the way a caller that only wants the whole drive sees it.
*
* The seam matters more than it looks. Every leg ends where the next begins, so a plain
* concatenation repeats that vertex — and this line is not only drawn: a via dropped on
* the route is projected onto it to work out which leg it belongs after, and that index
* is written to the database. A repeated point can put the via on the wrong leg, which
* is a stored wrong answer rather than an invisible one.
*/
function joinLegs(legs: ValhallaLeg[]): ValhallaLeg {
const coordinates: [number, number][] = []
for (const leg of legs) {
const last = coordinates[coordinates.length - 1]
const first = leg.coordinates[0]
const continues = last && first && last[0] === first[0] && last[1] === first[1]
coordinates.push(...(continues ? leg.coordinates.slice(1) : leg.coordinates))
}
return {
coordinates,
distance: legs.reduce((sum, l) => sum + l.distance, 0),
duration: legs.reduce((sum, l) => sum + l.duration, 0),
// One tolled leg makes the drive a tolled drive: `some`, never `every`, or a day
// with a single toll booth in it would be labelled toll-free.
hasToll: legs.some(l => l.hasToll),
hasHighway: legs.some(l => l.hasHighway),
hasFerry: legs.some(l => l.hasFerry),
}
}
/**
* The Valhalla answer as a leg, or null when it is not one.
*
* Split out because it is the whole of the format knowledge and the only part worth
* testing without a network: the native shape, not `format=osrm`. That flag exists and
* would have kept the response side unchanged, but it drops `has_toll` — the one field
* this is all for — and carries every intersection instead, which measured 5.7 times
* larger on Paris to Lyon. Translating a smaller answer beats parsing a familiar one.
*/
/**
* The answer read leg by leg, which is what a whole day needs.
*
* Valhalla gives every leg its own summary, so a chain comes back already divided the
* way a road trip day wants it: this stop to the next, with its own distance, its own
* drive time, and its own honest `has_toll`. Reading only the trip summary would flatten
* a day into one number and lose the per-leg times the schedule is built from.
*
* The fallback to the trip summary is for a single leg only. Where several legs carry no
* summary of their own there is no honest way to divide one total between them, and a
* guess here would arrive as a wrong arrival time rather than as a missing one.
*/
export function runFrom(data: unknown): Omit<ValhallaRun, 'snapped'> | null {
const trip = tripOf(data)
if (!trip || !Array.isArray(trip.legs) || !trip.legs.length) return null
const legs: ValhallaLeg[] = []
for (const leg of trip.legs) {
if (typeof leg.shape !== 'string') return null
const summary = leg.summary ?? (trip.legs.length === 1 ? trip.summary : undefined)
if (!summary) return null
// Precision 6, verified against a known point: decoded as 5 the first vertex of a
// Paris route reads 488.56, which is not a latitude; as 6 it reads 48.856193.
const measure = measured(summary, decodePolyline(leg.shape, 6))
if (!measure) return null
legs.push(measure)
}
return { legs, total: joinLegs(legs) }
}
interface ValhallaSummary { [key: string]: number | boolean | undefined }
function tripOf(data: unknown) {
return (data as {
trip?: { legs?: { shape?: string; summary?: ValhallaSummary }[]; summary?: ValhallaSummary }
})?.trip
}
/** A summary and a shape as a leg, or null when the summary carries no numbers. */
function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null {
if (!coordinates.length) return null
const length = Number(summary.length)
const time = Number(summary.time)
if (!Number.isFinite(length) || !Number.isFinite(time)) return null
return {
coordinates,
// Asked in kilometres, answered in kilometres, handed on in metres because that is
// what every caller already reads off an OSRM route.
distance: length * 1000,
duration: time,
hasToll: summary.has_toll === true,
hasHighway: summary.has_highway === true,
hasFerry: summary.has_ferry === true,
}
}
/**
* Whether a route actually avoids what it was asked to avoid.
*
* The reason this function exists rather than a boolean on the request: `use_tolls: 0`
* is a weighting, so between two points with no untolled connection Valhalla answers a
* tolled route and reports it honestly. Labelling that "No tolls" would be a lie on
* screen, and the flags are in the answer precisely so nobody has to guess.
*/
export function legAvoids(leg: ValhallaLeg, avoid: AvoidClass): boolean {
if (avoid === 'toll') return !leg.hasToll
if (avoid === 'motorway') return !leg.hasHighway
return !leg.hasFerry
}
@@ -95,6 +95,8 @@ interface DayPlanSidebarProps {
onSetRouteProfile?: (profile: string) => void
onAddPlace?: () => void
onAddPlaceToDay?: (placeId: number, dayId: number) => void
/** Open the place form already pointed at this day, to create a new place there. */
onCreatePlaceForDay?: (dayId: number) => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
canUndo?: boolean
@@ -146,6 +148,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onAddReservation,
onAddPlace,
onAddPlaceToDay,
onCreatePlaceForDay,
onNavigateToFiles,
routeShown = false,
routeProfile = 'driving',
@@ -1073,6 +1076,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onAddReservation,
onAddPlace,
onAddPlaceToDay,
onCreatePlaceForDay,
onNavigateToFiles,
routeShown,
routeProfile,
@@ -1244,6 +1248,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onAddReservation,
onAddPlace,
onAddPlaceToDay,
onCreatePlaceForDay,
onNavigateToFiles,
routeShown,
routeProfile,
@@ -1892,7 +1897,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
}}
>
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('dayplan.emptyDay')}</span>
{/* An empty day is where somebody wants to add something, so
the slot offers to do it instead of only stating the fact.
Without the handler (no edit rights) it stays the sentence. */}
{onCreatePlaceForDay ? (
<button type="button"
onClick={e => { e.stopPropagation(); onCreatePlaceForDay(day.id) }}
className="text-content-muted"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 12px', borderRadius: 8,
// A solid outline and a whisper of tint rather than the
// dashed grey placeholder it replaced: it is the one thing
// to do on an empty day, so it should read as an offer
// without competing with the day rows around it.
background: 'color-mix(in srgb, var(--accent) 4%, transparent)',
border: '1px solid var(--border-primary)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
transition: 'background 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'color-mix(in srgb, var(--accent) 10%, transparent)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'color-mix(in srgb, var(--accent) 4%, transparent)' }}
>
<Plus size={13} strokeWidth={2} /> {t('dayplan.addPlaceHere')}
</button>
) : (
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('dayplan.emptyDay')}</span>
)}
</div>
) : (
merged.map((item, idx) => {
@@ -4,7 +4,7 @@ import type { RouteSegment } from '../../types'
// Walking gets the foot icon; a plugin route profile ('plugin:…') gets the bolt —
// its legs carry the profile-true durationText anyway, the icon just signals that
// the time came from a plugin router (e.g. EV routing with charge time folded in).
function profileIcon(profile: string) {
export function profileIcon(profile: string) {
if (profile === 'walking') return Footprints
if (profile.startsWith('plugin:')) return Zap
return Car
@@ -467,41 +467,33 @@ describe('PlaceDetailsColumn — hours and rating', () => {
})
})
/**
* FE-PDC-027..029 — the nudge towards a Google key.
* FE-PDC-027 — nothing came back for this place.
*
* Deliberately narrow: it appears only when the free sources came up empty AND
* no key is configured. On an instance with a key there is nothing to suggest,
* and on a place the free sources described it would read as an advert.
* It used to be a line of grey text plus, when no Google key was set, a panel
* telling the reader to go and ask an administrator for one. The mascot says the
* same thing in the shape every other empty state in TREK uses, and whose key is
* missing is not this panel's business.
*/
describe('PlaceDetailsColumn — no Google key', () => {
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
describe('PlaceDetailsColumn — nothing found', () => {
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
renderColumn()
it('FE-PDC-027: suggests a key when nothing was found and none is set', async () => {
empty()
renderColumn({ hasMapsKey: false })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHint')).toBeInTheDocument()
})
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
empty()
renderColumn({ hasMapsKey: true })
await screen.findByText('places.details.nothing')
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
})
it('FE-PDC-029: stays quiet when the free sources did find something', async () => {
it('FE-PDC-028: stays quiet when the free sources did find something', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
})
renderColumn({ hasMapsKey: false })
renderColumn()
await screen.findByText('Ein Museum.')
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
})
})
/**
@@ -10,13 +10,11 @@ import {
ChevronUp,
Clock,
ExternalLink,
ImageOff,
Landmark,
Leaf,
Loader2,
ScrollText,
ShoppingBag,
Sparkles,
Sprout,
Star,
Sun,
@@ -27,6 +25,7 @@ import { mapsApi } from '../../api/client'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
import { safeHttpUrl } from '../../utils/safeUrl'
import EmptyState from '../shared/EmptyState'
import type { TranslationFn } from '../../types'
/** The place the column is describing. Null while nothing is selected. */
@@ -53,7 +52,6 @@ interface PlaceDetailsColumnProps {
/** For grouping the rating count's digits. */
locale?: string
/** False on an instance with no Google key, which is most of them. */
hasMapsKey?: boolean
t: TranslationFn
}
@@ -119,7 +117,6 @@ export default function PlaceDetailsColumn({
language,
timeFormat = '24h',
locale = 'en-US',
hasMapsKey = false,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
@@ -208,7 +205,17 @@ export default function PlaceDetailsColumn({
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5">
{!selection && <p className="text-caption text-content-muted">{t('places.details.empty')}</p>}
{/* Nothing picked yet, which is a state rather than a fault: the mascot
waits with an idle look instead of a sad one. */}
{!selection && (
<EmptyState
scene="idle"
title={t('places.details.empty')}
size={92}
fill
surface="var(--bg-secondary)"
/>
)}
{selection && state === 'loading' && (
<div className="flex items-center gap-2 text-caption text-content-muted">
@@ -223,17 +230,20 @@ export default function PlaceDetailsColumn({
<p className="text-caption text-content-muted">{t('places.details.disabled')}</p>
)}
{/* The mascot rather than a line of grey text and, under it, a paragraph
telling the reader to go and ask an administrator for a Google key.
Nothing came back for this place, which is worth saying once and
plainly; whose key is missing is not this panel's business, and on a
place the free sources describe fine it read as an advert. */}
{isEmpty && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-caption text-content-muted">
<ImageOff className="w-4 h-4 shrink-0" />
{t('places.details.nothing')}
</div>
{/* Only when both are true. With a key configured there is nothing
to suggest, and on a place the free sources DID describe the
suggestion would be an advert. */}
{!hasMapsKey && <NoKeyHint t={t} />}
</div>
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
)}
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
@@ -462,19 +472,6 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
* It also names who to ask: the key is an instance-wide setting, so on most
* installs the person reading this cannot act on it themselves.
*/
function NoKeyHint({ t }: { t: TranslationFn }): React.ReactElement {
return (
<div className="rounded-xl border border-accent/25 bg-accent-subtle p-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text">
<Sparkles className="h-3 w-3" />
</span>
<p className="text-caption font-semibold text-content">{t('places.details.noKeyTitle')}</p>
</div>
<p className="mt-2 text-caption leading-relaxed text-content-secondary">{t('places.details.noKeyHint')}</p>
</div>
)
}
/**
* The star rating, as stars.
@@ -299,6 +299,43 @@ describe('PlaceFormModal', () => {
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-021e: an OpenStreetMap suggestion keeps its own coordinates instead of searching for its label', async () => {
// The layer's second line is the name written on the building, not an
// address. Joining the two and searching for it asks a question nobody
// typed, and whatever came back first was taken as the place the user had
// already picked. The row carries coordinates, so the fallback uses those.
const user = userEvent.setup();
let searched = 0;
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{
placeId: 'node:9712313',
mainText: 'Tokio Hauptbahnhof',
secondaryText: '東京駅丸の内駅舎',
source: 'openstreetmap',
lat: 35.6811816,
lng: 139.76598265,
}],
source: 'trek-places',
}),
),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
http.post('/api/maps/search', () => {
searched += 1;
return HttpResponse.json({ places: [{ name: 'Etwas ganz anderes', lat: '1', lng: '1' }], source: 'trek-places' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokio');
await user.click(await screen.findByText('Tokio Hauptbahnhof'));
expect(await screen.findByDisplayValue('35.6811816')).toBeInTheDocument();
expect(screen.getByDisplayValue('139.76598265')).toBeInTheDocument();
expect(searched).toBe(0);
});
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
const user = userEvent.setup();
server.use(
@@ -349,10 +386,80 @@ describe('PlaceFormModal', () => {
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
// hasMapsKey is false by default in beforeEach
it('FE-PLANNER-PLACEFORM-022: no standing notice about which index is in use', () => {
// It used to say "Using OpenStreetMap" above the box whenever no Google key
// was set, which was a sentence about configuration in the middle of a form
// about a place. Each result names its own source now, which is the question
// people actually had.
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByText(/OpenStreetMap/i)).toBeInTheDocument();
expect(screen.queryByText(/Using OpenStreetMap/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022b: a suggestion says which index answered', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'gers:abc', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'trek-places',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
expect(await screen.findByText('TREK')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022d: the typed-ahead list marks the OpenStreetMap rows as such', async () => {
// The keystroke path asks both indexes at once, so the name the response
// carries for the whole list ('trek-places') is true of the call and wrong
// for half the rows. Marking every suggestion TREK is how a list that did
// contain OpenStreetMap places read as if it never had.
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [
{ placeId: 'gers:abc', mainText: 'Tokyo Station Beer Stand', secondaryText: 'Chiyoda', source: 'trek-places' },
{ placeId: 'node:9712313', mainText: 'Tokio Hauptbahnhof', secondaryText: '東京駅', source: 'openstreetmap' },
],
source: 'trek-places',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokyo Station');
expect(await screen.findByText('TREK')).toBeInTheDocument();
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => {
// The whole point of putting it on the row: with both indexes answering,
// a label above the list could only name one of them. The index marks its
// own rows, so the unmarked one is OpenStreetMap by elimination.
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [
{ name: 'Eiffel Tower', address: 'Paris', lat: '48.85', lng: '2.29', source: 'trek-places' },
{ name: 'Champ de Mars', address: 'Paris', lat: '48.85', lng: '2.29' },
],
source: 'trek-places+openstreetmap',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await user.keyboard('{Enter}');
expect(await screen.findByText('TREK')).toBeInTheDocument();
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
});
// ── Category ─────────────────────────────────────────────────────────────────
@@ -1,7 +1,6 @@
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'
@@ -19,6 +18,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 { sourceLabelFor } from '../../utils/placeSource'
import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -56,6 +56,32 @@ interface PlaceFormModalProps {
}
/**
* One row of the typed-ahead list, as the server sends it.
*
* `source`, `lat` and `lng` are optional because not every index fills them:
* Google answers with neither, and the mark falls back to the name the whole
* list carries.
*/
type Suggestion = {
placeId: string
mainText: string
secondaryText: string
source?: string
lat?: number
lng?: number
}
/** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */
function SourceBadge({ label }: { label: string | null }) {
if (!label) return null
return (
<span className="shrink-0 rounded-md border border-edge bg-surface-secondary px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
{label}
</span>
)
}
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
* render over the form fields. */
@@ -92,10 +118,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
} = 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 answered the last full search. Only a fallback: a merged list carries the source per place. */
const [searchSource, setSearchSource] = useState<string>('')
/**
* 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
@@ -118,7 +145,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
const [acSuggestions, setAcSuggestions] = useState<Suggestion[]>([])
// Which index answered the last keystroke, for the rows that do not say so
// themselves. Google and the OpenStreetMap fallback each answer from one
// place; the index path answers from two at once and marks every row.
const [acSource, setAcSource] = useState<string>('')
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acAbortRef = useRef<AbortController | null>(null)
@@ -255,6 +286,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
acMetaRef.current = { query, source: result.source || 'unknown' }
setAcSuggestions(result.suggestions || [])
setAcSource(result.source || '')
setAcHighlight(-1)
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return
@@ -315,6 +347,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
setMapsResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
@@ -371,7 +404,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setMapsSearch('')
}
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
const handleSelectSuggestion = async (suggestion: Suggestion) => {
// 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
@@ -399,6 +432,21 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
} catch (err) {
console.error('Failed to fetch place details:', err)
}
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
// The layer's rows carry no address; their second line is the name
// written on the building. Searching for "Tokio Hauptbahnhof, 東京駅"
// is not a question anybody asked, and its first answer would be
// whatever the index made of it — a different place, chosen silently.
// The suggestion already knows where it is, so use that.
place = {
name: suggestion.mainText,
address: '',
lat: suggestion.lat,
lng: suggestion.lng,
osm_id: suggestion.placeId,
source: 'openstreetmap',
}
}
if (!place) {
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(query, language, locationBiasPoint)
@@ -569,7 +617,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -589,6 +636,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
fileRef,
acSuggestions,
setAcSuggestions,
acSource,
searchSource,
acHighlight,
setAcHighlight,
acDebounceRef,
@@ -642,7 +691,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -662,6 +710,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
fileRef,
acSuggestions,
setAcSuggestions,
acSource,
searchSource,
acHighlight,
setAcHighlight,
acDebounceRef,
@@ -743,18 +793,12 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
language={language}
timeFormat={S.timeFormat}
locale={S.locale}
hasMapsKey={S.hasMapsKey}
t={t}
/>
)}
<form onSubmit={handleSubmit} className={twoColumn || showDetails ? 'flex-1 min-w-0 space-y-3' : 'space-y-3'} onPaste={handlePaste}>
{/* Place Search */}
<div className="bg-surface-secondary rounded-xl p-3 border border-edge">
{!hasMapsKey && (
<p className="mb-2 text-xs text-content-faint">
{t('places.osmActive')}
</p>
)}
<div className="relative">
<div className="flex gap-2">
<input
@@ -795,10 +839,15 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
idx === acHighlight ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'
}`}
>
<div className="font-medium text-sm">{s.mainText}</div>
{s.secondaryText && (
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
)}
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{s.mainText}</div>
{s.secondaryText && (
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
)}
</div>
<SourceBadge label={sourceLabelFor(s, acSource)} />
</div>
</button>
))}
</div>
@@ -815,8 +864,13 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
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>
<div className="text-xs text-content-muted truncate">{result.address}</div>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{result.name}</div>
<div className="text-xs text-content-muted truncate">{result.address}</div>
</div>
<SourceBadge label={sourceLabelFor(result, searchSource)} />
</div>
</button>
))}
</div>
@@ -914,30 +968,6 @@ 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>
@@ -88,6 +88,39 @@ describe('PlacesSidebar', () => {
expect(onAddPlace).toHaveBeenCalled();
});
/**
* The split add button (a day is open).
*
* Two buttons rather than one, because the pool sits a click away from the plan
* and a place you already know belongs to today should not need a second trip
* through the day picker. The second one stays mounted and collapsed so it has
* something to animate out of.
*/
it('FE-COMP-PLACES-005b: with no day open there is one add button, at full length', () => {
render(<PlacesSidebar {...defaultProps} selectedDayId={null} onAddPlaceToSelectedDay={vi.fn()} />);
expect(screen.getAllByText(/Add Place\/Activity/i).length).toBeGreaterThan(0);
expect(screen.queryByText('New place')).not.toBeInTheDocument();
// Present in the DOM so it can animate, but out of reach until a day is open.
const quick = screen.getByTestId('add-place-to-day');
expect(quick).toHaveAttribute('aria-hidden', 'true');
expect(quick).toHaveAttribute('tabindex', '-1');
});
it('FE-COMP-PLACES-005c: an open day splits the button and shortens the main label', async () => {
const user = userEvent.setup();
const onAddPlaceToSelectedDay = vi.fn();
render(<PlacesSidebar {...defaultProps} selectedDayId={7} onAddPlaceToSelectedDay={onAddPlaceToSelectedDay} />);
// Shortened so both fit side by side without either truncating.
expect(screen.getByText('New place')).toBeInTheDocument();
const quick = screen.getByTestId('add-place-to-day');
expect(quick).toHaveAttribute('tabindex', '0');
await user.click(quick);
expect(onAddPlaceToSelectedDay).toHaveBeenCalled();
});
it('FE-COMP-PLACES-006: clicking a place calls onPlaceClick with place id', async () => {
const user = userEvent.setup();
const onPlaceClick = vi.fn();
@@ -255,6 +288,113 @@ describe('Filter tabs', () => {
expect(screen.getByText('Planned Place')).toBeInTheDocument();
expect(screen.queryByText('Unplanned Place')).not.toBeInTheDocument();
});
// ── The open day narrows the pool, and says so ──────────────────────────────
//
// The map has followed the selected day on this filter since #2024 while the list
// did not, so a trip with everything planned read "55" in the pool beside five pins
// on the map. Reported twice in the same Discord thread as the map being broken.
it('FE-PLANNER-SIDEBAR-052: with a day open, "Planned" shows that day, not the whole trip', () => {
const today = buildPlace({ id: 91, name: 'On This Day' });
const otherDay = buildPlace({ id: 92, name: 'On Another Day' });
const assignments = {
'1': [buildAssignment({ place: today, day_id: 1 })],
'2': [buildAssignment({ place: otherDay, day_id: 2 })],
};
seedStore(useTripStore, { placesFilter: 'planned' });
render(
<PlacesSidebar
{...defaultProps}
places={[today, otherDay]}
assignments={assignments}
days={[{ id: 1 }, { id: 2 }] as never}
selectedDayId={1}
/>,
);
expect(screen.getByText('On This Day')).toBeInTheDocument();
expect(screen.queryByText('On Another Day')).not.toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-053: without a day open it is the whole trip again', () => {
const dayOne = buildPlace({ id: 91, name: 'On This Day' });
const dayTwo = buildPlace({ id: 92, name: 'On Another Day' });
const assignments = {
'1': [buildAssignment({ place: dayOne, day_id: 1 })],
'2': [buildAssignment({ place: dayTwo, day_id: 2 })],
};
seedStore(useTripStore, { placesFilter: 'planned' });
render(
<PlacesSidebar
{...defaultProps}
places={[dayOne, dayTwo]}
assignments={assignments}
days={[{ id: 1 }, { id: 2 }] as never}
selectedDayId={null}
/>,
);
expect(screen.getByText('On This Day')).toBeInTheDocument();
expect(screen.getByText('On Another Day')).toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-054: the note only appears while the day is actually narrowing', () => {
const today = buildPlace({ id: 91, name: 'On This Day' });
const assignments = { '1': [buildAssignment({ place: today, day_id: 1 })] };
const onClearSelectedDay = vi.fn();
seedStore(useTripStore, { placesFilter: 'planned' });
const { rerender } = render(
<PlacesSidebar
{...defaultProps}
places={[today]}
assignments={assignments}
days={[{ id: 1 }] as never}
selectedDayId={1}
onClearSelectedDay={onClearSelectedDay}
/>,
);
expect(screen.getByText('Showing the open day only')).toBeInTheDocument();
// Dismissing it asks for the day to be closed rather than changing the filter.
fireEvent.click(screen.getByLabelText('Show the whole trip'));
expect(onClearSelectedDay).toHaveBeenCalledTimes(1);
// No day open, nothing narrowed, no note.
rerender(
<PlacesSidebar
{...defaultProps}
places={[today]}
assignments={assignments}
days={[{ id: 1 }] as never}
selectedDayId={null}
onClearSelectedDay={onClearSelectedDay}
/>,
);
expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-055: "Unplanned" stays trip-wide while a day is open', () => {
// A place assigned to some other day is planned, whichever day happens to be open,
// so it must not reappear in the unplanned pool.
const otherDay = buildPlace({ id: 92, name: 'On Another Day' });
const loose = buildPlace({ id: 93, name: 'Not Planned At All' });
const assignments = { '2': [buildAssignment({ place: otherDay, day_id: 2 })] };
seedStore(useTripStore, { placesFilter: 'unplanned' });
render(
<PlacesSidebar
{...defaultProps}
places={[otherDay, loose]}
assignments={assignments}
days={[{ id: 1 }, { id: 2 }] as never}
selectedDayId={1}
/>,
);
expect(screen.getByText('Not Planned At All')).toBeInTheDocument();
expect(screen.queryByText('On Another Day')).not.toBeInTheDocument();
expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument();
});
});
// ── Search ────────────────────────────────────────────────────────────────────
@@ -43,7 +43,19 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
<PlacesSelectionBar {...S} />
) : (
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
{/* A badge across the whole rail rather than a line of text hugging the
left edge: it reads as the list's header instead of as a stray label.
Outlined rather than filled, because the tertiary surface is a slate
tone and put a blue cast on the panel. */}
<div className="text-content-faint" style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '4px 10px', borderRadius: 99,
background: 'transparent', border: '1px solid var(--border-faint)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>
{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}
</div>
</div>
)}
@@ -1,8 +1,18 @@
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star } from 'lucide-react'
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react'
import { getCategoryIcon } from '../shared/categoryIcons'
import Tooltip from '../shared/Tooltip'
import { useElementSize } from '../../hooks/useElementSize'
import type { SidebarState } from './usePlacesSidebar'
/**
* Below this the two labels stop fitting side by side and both buttons fall back
* to their icon. Measured on the button row itself rather than derived from the
* sidebar width: the labels are translated, so how much room they need differs
* per locale, and the row is what actually runs out of space. The rail is
* draggable down to 200px, which leaves the row 168.
*/
const COMPACT_BUTTONS_WIDTH = 232
export function PlacesDropOverlay({ t }: SidebarState) {
return (
<div style={{
@@ -21,52 +31,118 @@ export function PlacesDropOverlay({ t }: SidebarState) {
export function PlacesHeader(S: SidebarState) {
const {
canEditPlaces, onAddPlace, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
canEditPlaces, onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
places, categories, categoryFilters, search, setSearch, plannedIds, plannedFilterIds, dayScoped, onClearSelectedDay, hasTracks,
filter, setFilter, setSelectedIds, selectMode, setSelectMode,
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters,
ratingFilter, setRatingFilter,
starDropOpen, setStarDropOpen,
} = S
const dayOpen = selectedDayId != null
const { ref: buttonRowRef, width: buttonRowWidth } = useElementSize<HTMLDivElement>()
// Zero is the first paint, before the observer has measured anything — treat
// that as roomy so the labels do not flash away and back on every mount.
const compact = buttonRowWidth > 0 && buttonRowWidth < COMPACT_BUTTONS_WIDTH
const addLabel = t(dayOpen ? 'places.addPlaceShort' : 'places.addPlace')
const fileImportLabel = t('places.importFile')
const listImportLabel = t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')
return (
<div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
{canEditPlaces && <button type="button"
onClick={onAddPlace}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 12px', borderRadius: 12, border: 'none',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 10,
}}
>
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button>}
{canEditPlaces && <div ref={buttonRowRef} style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
{/* The label shortens while the second button is out, so both fit side by
side in a default rail without either one truncating; a squeezed rail
drops it entirely and the aria-label carries the name instead. */}
<button type="button"
onClick={onAddPlace}
aria-label={compact ? addLabel : undefined}
title={compact ? addLabel : undefined}
className="bg-accent text-accent-text motion-reduce:transition-none"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
// Both halves are driven by flex-basis rather than by grow, so the
// pair lands on an exact 50/50 and the handover animates in both
// directions. Half of the 6px gap comes off each side.
flexGrow: 0, flexShrink: 1, flexBasis: dayOpen ? 'calc(50% - 3px)' : '100%',
minWidth: 0, padding: '8px 12px', borderRadius: 12, border: 'none',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
overflow: 'hidden', whiteSpace: 'nowrap',
transition: 'flex-basis 180ms ease',
}}
>
<Plus size={14} strokeWidth={2} />
{!compact && (
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{addLabel}
</span>
)}
</button>
{/* Kept mounted and collapsed rather than unmounted, so it has something
to animate out of: a button that only exists while a day is open
would appear and vanish on the spot. Hidden from the tab order and
from screen readers in the same breath, because a zero-width button
is still focusable otherwise. */}
{onAddPlaceToSelectedDay && (
<Tooltip label={t('places.addToSelectedDay')} disabled={!dayOpen}>
<button type="button"
onClick={onAddPlaceToSelectedDay}
aria-label={t('places.addToSelectedDay')}
data-testid="add-place-to-day"
aria-hidden={!dayOpen}
tabIndex={dayOpen ? 0 : -1}
className="bg-accent text-accent-text motion-reduce:transition-none"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
flexGrow: 0, flexShrink: 1, flexBasis: dayOpen ? 'calc(50% - 3px)' : '0%',
minWidth: 0,
opacity: dayOpen ? 1 : 0,
padding: dayOpen ? '8px 12px' : 0,
borderRadius: 12, border: 'none',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', overflow: 'hidden', whiteSpace: 'nowrap',
pointerEvents: dayOpen ? 'auto' : 'none',
transition: 'flex-basis 180ms ease, opacity 140ms ease, padding 180ms ease',
}}
>
<CalendarPlus size={14} strokeWidth={2} style={{ flexShrink: 0 }} />
{!compact && t('places.addToDayShort')}
</button>
</Tooltip>
)}
</div>}
{canEditPlaces && <>
{/* Same squeeze rule as the row above, measured off the same rail: once the
add buttons lose their labels these two would not fit either. */}
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button type="button"
onClick={() => setFileImportOpen(true)}
aria-label={compact ? fileImportLabel : undefined}
title={compact ? fileImportLabel : undefined}
className="border border-dashed border-edge text-content-faint"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8,
flex: 1, minWidth: 0, padding: '5px 12px', borderRadius: 8,
background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
overflow: 'hidden', whiteSpace: 'nowrap',
}}
>
<FileDown size={11} strokeWidth={2} /> {t('places.importFile')}
<FileDown size={11} strokeWidth={2} /> {!compact && fileImportLabel}
</button>
<button type="button"
onClick={() => setListImportOpen(true)}
aria-label={compact ? listImportLabel : undefined}
title={compact ? listImportLabel : undefined}
className="border border-dashed border-edge text-content-faint"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8,
flex: 1, minWidth: 0, padding: '5px 12px', borderRadius: 8,
background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
overflow: 'hidden', whiteSpace: 'nowrap',
}}
>
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
<MapPin size={11} strokeWidth={2} /> {!compact && listImportLabel}
</button>
</div>
<div className="bg-edge" style={{ height: 1, margin: '2px 0 10px' }} />
@@ -87,7 +163,11 @@ export function PlacesHeader(S: SidebarState) {
const counts = {
all: baseFiltered.length,
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
planned: baseFiltered.filter(p => plannedIds.has(p.id)).length,
// While a day is open this counts that day's plan, the same set the list and
// the map show. Counting the whole trip here is what made the tab read 55
// beside five pins, with nothing to say the two were answering different
// questions.
planned: baseFiltered.filter(p => plannedFilterIds.has(p.id)).length,
tracks: baseFiltered.filter(p => p.route_geometry).length,
}
const tabs = ([
@@ -97,7 +177,11 @@ export function PlacesHeader(S: SidebarState) {
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'planned' | 'tracks'; label: string }>
return (
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
// The group always spans the rail and never wraps onto a second line.
// Each tab keeps its own text width and only the leftover space is
// shared out, so "Unplanned" stays wider than "All" instead of the four
// being forced to one size.
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'nowrap' }}>
{tabs.map(f => {
const active = filter === f.id
return (
@@ -107,18 +191,20 @@ export function PlacesHeader(S: SidebarState) {
className={active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '4px 9px', borderRadius: 99,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: '1 1 auto', minWidth: 0, overflow: 'hidden',
padding: compact ? '4px 5px' : '4px 9px', borderRadius: 99,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, whiteSpace: 'nowrap',
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
}}
>
{f.label}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.label}</span>
<span className={active ? 'text-accent-text' : 'text-content-faint'} style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, lineHeight: 1,
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
flexShrink: 0,
}}>
{counts[f.id]}
</span>
@@ -129,6 +215,42 @@ export function PlacesHeader(S: SidebarState) {
)
})()}
{/* Says out loud what the count above already narrowed to.
The map has followed the open day on this filter since #2024, and until now
nothing anywhere said so: the pool read 55, the map drew five, and the honest
conclusion was that the map was broken. It sits under the tabs rather than on
the map because a chip over the canvas is unreachable on a phone, which is
where this was reported from. */}
{dayScoped && (
<div
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px 4px 9px', borderRadius: 8, marginBottom: 8,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
}}
>
<CalendarDays size={11} strokeWidth={2} style={{ flexShrink: 0 }} />
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t('places.dayScoped')}
</span>
{onClearSelectedDay && (
<button type="button"
onClick={onClearSelectedDay}
aria-label={t('places.dayScopedClear')}
title={t('places.dayScopedClear')}
className="text-content-faint hover:text-content"
style={{
marginInlineStart: 'auto', display: 'flex', alignItems: 'center',
background: 'none', border: 'none', padding: 3, cursor: 'pointer', flexShrink: 0,
}}
>
<X size={12} strokeWidth={2.2} />
</button>
)}
</div>
)}
{/* Suchfeld */}
<div style={{ position: 'relative' }}>
<Search size={13} strokeWidth={1.8} color="var(--text-faint)" style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }} />
@@ -137,10 +259,14 @@ export function PlacesHeader(S: SidebarState) {
value={search}
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
placeholder={t('places.search')}
className="bg-surface-tertiary text-content"
className="bg-surface-card text-content"
style={{
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
// Borderless on the tertiary surface it sat almost flush with the
// panel; the outline is the one the category dropdown below already
// uses, so the two read as the same kind of control.
border: '1px solid var(--border-primary)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box',
}}
/>
@@ -1,4 +1,5 @@
import { Fragment } from 'react'
import EmptyState from '../shared/EmptyState'
import { MemoPlaceRow } from './PlacesSidebarRow'
import type { SidebarState } from './usePlacesSidebar'
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
@@ -14,14 +15,26 @@ export function PlacesList(S: SidebarState) {
return (
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
{filtered.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
</span>
{canEditPlaces && <button type="button" onClick={onAddPlace} className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('places.addPlace')}
</button>}
</div>
/* The mascot and one line, the shape every other empty state in TREK has.
The add link stays as the state's action: an empty list of places is
one of the few that has an obvious next step. */
<EmptyState
scene="search"
mood="sad"
size={92}
fill
surface="var(--bg-secondary)"
title={filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
action={canEditPlaces ? (
<button
type="button"
onClick={onAddPlace}
className="text-caption text-content underline underline-offset-2 hover:text-accent"
>
{t('places.addPlace')}
</button>
) : undefined}
/>
) : (
filtered.map(place => {
const cat = categories.find(c => c.id === place.category_id)
@@ -15,7 +15,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { safeHttpUrl } from '../../utils/safeUrl'
import { plannedPlaceIds, type PlannedAccommodation } from '../../utils/plannedPlaces'
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
/** Stable identity — a fresh [] default would invalidate the planned memo on every render. */
const NO_ACCOMMODATIONS: PlannedAccommodation[] = []
@@ -31,6 +31,21 @@ export interface PlacesSidebarProps {
selectedPlaceId: number | null
onPlaceClick: (placeId: number | null) => void
onAddPlace: () => void
/**
* Create a place and drop it straight into the day that is open.
*
* Only reachable while a day is selected, which is what the split button in
* the header is about: the pool is one click away from the plan, and adding a
* place you already know belongs to today should not need a second trip
* through the day picker.
*/
onAddPlaceToSelectedDay?: () => void
/**
* Close the open day, from the note that says the pool is showing only that day.
* Absent leaves the note without its dismiss, which is what the mobile day picker
* wants: there the day is closed by the picker itself.
*/
onClearSelectedDay?: () => void
onAssignToDay: (placeId: number, dayId: number) => void
onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void
@@ -51,7 +66,7 @@ export interface PlacesSidebarProps {
*/
export function usePlacesSidebar(props: PlacesSidebarProps) {
const {
tripId, places, assignments, selectedDayId, accommodations = NO_ACCOMMODATIONS,
tripId, places, assignments, selectedDayId, days, accommodations = NO_ACCOMMODATIONS,
pushUndo, initialScrollTop, onScrollTopChange,
} = props
const { t } = useTranslation()
@@ -229,10 +244,30 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
[assignments, accommodations, reservations],
)
/**
* What "planned" means while a day is open: that day's plan, not the whole trip's.
*
* The map has narrowed to the selected day since #2024, and the list did not, which is
* how a trip with 55 planned places showed 55 in the pool and five pins on the map with
* nothing to explain the gap — read, reasonably, as the map being broken. The list now
* follows the map, and `dayScoped` tells the header to say so.
*
* Only "planned" narrows. "Unplanned" stays trip-wide on purpose: a place assigned to
* some other day is planned, whichever day happens to be open.
*/
const plannedInDayIds = useMemo(
() => (selectedDayId
? plannedPlaceIdsForDay(selectedDayId, days, { assignments, accommodations, reservations })
: null),
[selectedDayId, days, assignments, accommodations, reservations],
)
const plannedFilterIds = plannedInDayIds ?? plannedIds
const dayScoped = filter === 'planned' && plannedInDayIds !== null
const filtered = useMemo(() => {
const list = places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (filter === 'planned' && !plannedIds.has(p.id)) return false
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
if (filter === 'tracks' && !p.route_geometry) return false
if (categoryFilters.size > 0) {
if (p.category_id == null) {
@@ -245,7 +280,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
return true
})
return list
}, [places, filter, categoryFilters, search, plannedIds, ratingFilter])
}, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter])
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
if (element) {
@@ -313,7 +348,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
markSelectionVisited, markVisitedBusy,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
hasTracks, plannedIds, plannedFilterIds, dayScoped, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
}
}
@@ -702,6 +702,8 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
}}
onError={() => setLoadFailed(true)}
sandbox="allow-scripts allow-forms"
allowFullScreen
allow="fullscreen"
referrerPolicy="no-referrer"
loading="lazy"
title={title || pluginId}
@@ -0,0 +1,261 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import AnchoredPopover from './AnchoredPopover'
/**
* FE-ANCHOREDPOP-001..014 — where the panel lands and what makes it go away.
*
* Everything here is geometry read off the page and arithmetic nobody sees until it is
* wrong: a panel a gap below the badge, above it on a short window, pulled back inside
* the viewport, centred on the rail rather than on the badge. The numbers are worked out
* in the comments so a changed rule fails with a readable diff instead of a shrug.
*
* jsdom lays nothing out — every getBoundingClientRect comes back as zeroes, so badge,
* rail and panel all measure the same and every branch of the placement collapses onto
* 0,0. Sizes are therefore fed in per element, and window.innerHeight / innerWidth are
* stated rather than left to the jsdom default, since the flip and the clamp are the
* whole point of half these cases.
*/
const rect = (top: number, left: number, width: number, height: number): DOMRect => ({
top, left, width, height,
bottom: top + height,
right: left + width,
x: left,
y: top,
toJSON: () => ({}),
}) as DOMRect
let section: HTMLElement
let anchor: HTMLElement
let panel: DOMRect
function viewport(width: number, height: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width })
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height })
}
beforeEach(() => {
section = document.createElement('section')
anchor = document.createElement('button')
section.appendChild(anchor)
document.body.appendChild(section)
// The rail as it stands on a desktop: a tall column at the left edge with the badge
// hard against its far side. That offset is what the centring rule exists for.
section.getBoundingClientRect = () => rect(100, 0, 400, 600)
anchor.getBoundingClientRect = () => rect(120, 344, 48, 20)
panel = rect(0, 0, 200, 150)
viewport(1200, 800)
// The panel is the one element that cannot be primed by hand: React makes it inside the
// portal and the layout effect measures it in the tick it is created. So it answers off
// the prototype, which the two own assignments above shadow. Only its size is read —
// handing back a top and a left it does not have yet would make the test circular.
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => panel)
})
afterEach(() => {
// Both, because case 006 moves the anchor out of the section to test the fallback, so
// it would outlive section.remove() and sit in the document for the rest of the file.
anchor.remove()
section.remove()
vi.restoreAllMocks()
})
function open() {
const onClose = vi.fn()
const view = render(
<AnchoredPopover anchor={anchor} label="Stop kind" onClose={onClose}>
<button>Inside</button>
</AnchoredPopover>,
)
return { onClose, unmount: view.unmount, rerender: view.rerender }
}
const dialog = () => screen.getByRole('dialog')
describe('AnchoredPopover', () => {
it('FE-ANCHOREDPOP-001: without an anchor there is nothing to hang under, and nothing listening', () => {
const onClose = vi.fn()
render(
<AnchoredPopover anchor={null} label="Stop kind" onClose={onClose}>
<button>Inside</button>
</AnchoredPopover>,
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
// A closed panel that keeps its key handler takes Escape away from whatever is
// genuinely open behind it.
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).not.toHaveBeenCalled()
})
it('FE-ANCHOREDPOP-002: it hangs a gap under the badge, and is shown only once it has been placed', () => {
open()
// Badge bottom 140 plus the gap. Fixed is asserted as the class rather than as a
// computed style, because jsdom resolves no stylesheet: it is what makes every
// number in this file a viewport coordinate instead of an offset inside a scroller.
expect(dialog()).toHaveStyle({ top: '148px' })
expect(dialog().className).toContain('fixed')
expect(screen.getByRole('dialog', { name: 'Stop kind' })).toBeInTheDocument()
})
it('FE-ANCHOREDPOP-015: it is portalled to the body, out of the rail that would clip it', () => {
open()
// The reason this component exists at all. The rail scrolls and carries transforms,
// and both of those trap a fixed child, so a panel rendered where it is declared
// would be clipped by the sidebar it hangs in. Every other case here passes without
// the portal, which is why this one asserts the parent rather than the geometry.
expect(dialog().parentElement).toBe(document.body)
expect(section.contains(dialog())).toBe(false)
})
it('FE-ANCHOREDPOP-016: moving to another badge re-measures against that one', () => {
const { onClose, rerender } = open()
const second = document.createElement('button')
section.appendChild(second)
second.getBoundingClientRect = () => rect(500, 344, 48, 20)
rerender(
<AnchoredPopover anchor={second} label="Stop kind" onClose={onClose}>
<button>Inside</button>
</AnchoredPopover>,
)
// 500 plus 20 plus the gap. The rail has more than one badge worth opening and the
// same panel serves all of them, so the anchor really does change under a mounted
// popover. Measuring in a layout effect is what keeps that from ever being painted
// at the previous badge: the position is committed before the browser draws.
expect(dialog()).toHaveStyle({ top: '528px' })
second.remove()
})
it('FE-ANCHOREDPOP-003: on a short window it flips above the badge instead of off the bottom', () => {
anchor.getBoundingClientRect = () => rect(200, 344, 48, 20)
viewport(1200, 360)
open()
// Below would start at 228 and run to 378, past the 352 the window leaves. Above is
// badge top 200 less the 150 it needs less the gap.
expect(dialog()).toHaveStyle({ top: '42px' })
})
it('FE-ANCHOREDPOP-004: a panel too tall for either side sits at the top edge rather than above it', () => {
anchor.getBoundingClientRect = () => rect(40, 344, 48, 20)
panel = rect(0, 0, 200, 250)
viewport(1200, 320)
open()
// Flipping honestly would put it at -218, scrolling the half that holds the controls
// out of reach with nothing to scroll back. Covering the badge is the lesser evil.
expect(dialog()).toHaveStyle({ top: '8px' })
})
it('FE-ANCHOREDPOP-005: it centres on the rail, not on the badge sitting at the edge of it', () => {
open()
// Rail centre 200 less half the panel. Centred on the badge it would be 268, hanging
// over the map and pointing at nothing.
expect(dialog()).toHaveStyle({ left: '100px' })
})
it('FE-ANCHOREDPOP-006: with no column around it, it falls back to the badge', () => {
// Same badge, no rail: the panel is not owned by the sidebar and has to place itself
// sanely wherever it is hung.
document.body.appendChild(anchor)
open()
// Badge centre 368 less half the panel.
expect(dialog()).toHaveStyle({ left: '268px' })
})
it('FE-ANCHOREDPOP-007: a rail near the right edge does not push the panel off the screen', () => {
section.getBoundingClientRect = () => rect(100, 380, 200, 600)
viewport(500, 800)
open()
// Centred it would want 380 and end at 580, well past a window 500 wide. Clamped it
// ends at 492, the same margin the rest of the placement keeps.
expect(dialog()).toHaveStyle({ left: '292px' })
})
it('FE-ANCHOREDPOP-008: a rail scrolled off the left does not push the panel off it either', () => {
section.getBoundingClientRect = () => rect(100, -60, 100, 600)
open()
// Centred on that sliver the panel would start at -110 and lose its first half.
expect(dialog()).toHaveStyle({ left: '8px' })
})
it('FE-ANCHOREDPOP-009: Escape closes it, and only Escape', () => {
// The handler sits on the document, so it sees every key pressed anywhere on the
// page, typing in a field included.
const { onClose } = open()
fireEvent.keyDown(document, { key: 'Enter' })
fireEvent.keyDown(document, { key: 'e' })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-ANCHOREDPOP-010: a press anywhere else closes it', () => {
const { onClose } = open()
fireEvent.mouseDown(document.body)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-ANCHOREDPOP-011: a press on what the panel contains leaves it open', () => {
// It closes on mousedown, not on click. Without the guard a button in the panel would
// be torn out from under the pointer before its own click ever landed.
const { onClose } = open()
fireEvent.mouseDown(screen.getByText('Inside'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-ANCHOREDPOP-012: a press on the badge that opened it leaves it open too', () => {
// The badge toggles. Closing here as well means one press closes the panel twice, and
// the next press reads as the badge refusing to open.
const { onClose } = open()
fireEvent.mouseDown(anchor)
expect(onClose).not.toHaveBeenCalled()
})
it('FE-ANCHOREDPOP-013: a scroll closes it, including one that never bubbles up', () => {
// Scroll events do not bubble and the rail is its own scroller. A listener without
// capture would hear nothing while the badge slides out from under a panel that stays
// where it was measured.
const { onClose } = open()
fireEvent.scroll(section)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-ANCHOREDPOP-014: once it is gone it has stopped listening', () => {
// Three handlers on the document and the window, on a component that is mounted and
// thrown away again for every badge in the rail.
const { onClose, unmount } = open()
unmount()
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.mouseDown(document.body)
fireEvent.scroll(section)
expect(onClose).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,84 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
/**
* A small panel hung under something in the rail.
*
* The rail has more than one badge worth opening — what kind of stop this is, how full it
* fills — and every one of them needs the same four things: measure before showing,
* flip above when the window is short, close on an outside click or Escape, and close on
* a scroll rather than chase the anchor. Each is one line to get wrong and none of them
* is about what the panel contains, which is why they live here once instead of beside
* every panel that needs them.
*
* Deliberately not the `CustomSelect` machinery: that one is a select, with a value, a
* search and a keyboard model. This is a surface.
*/
export default function AnchoredPopover({ anchor, label, children, onClose }: {
/** The element the panel hangs under. Null renders nothing, which is how it stays closed. */
anchor: HTMLElement | null
/** What the dialog is called, for anyone who cannot see where it is hanging. */
label: string
children: React.ReactNode
onClose: () => void
}): React.ReactElement | null {
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 anchor. A badge sits hard against one 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={label}
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' }}
>
{children}
</div>,
document.body,
)
}
@@ -0,0 +1,228 @@
import React from 'react'
import { describe, it, expect } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import type { DistanceUnit } from '../../types'
import RangeStrip, { blockMask } from './RangeStrip'
/**
* FE-RANGESTRIP-001..015 — the picture under the range field.
*
* Nothing here is interactive, so what is worth pinning is the arithmetic the drawing
* rests on: the empty state has to stay empty rather than settle on a plausible zero,
* the block mask has a band it lives in and looks broken outside it, and the three
* segments have to cover the bar exactly or the tail runs off the end. The chips are the
* only readable proof that any of that happened, and the aria-label is the only route a
* screen reader has to the figure at all.
*/
const strip = (over: Partial<React.ComponentProps<typeof RangeStrip>> = {}) => (
<RangeStrip
rangeKm={500}
fillPercent={null}
wearPercent={null}
unit={'metric' as DistanceUnit}
electric={false}
{...over}
/>
)
/**
* The three widths of the bar, in the order they are laid: what a stop puts in, what is
* left of the tank, what age has taken. Read off the track rather than off the wrapper,
* because the wrapper is the thing carrying the label.
*/
function segments(): number[] {
const track = screen.getByRole('img').firstElementChild as HTMLElement
return Array.from(track.children).map(c => parseFloat((c as HTMLElement).style.width))
}
describe('RangeStrip', () => {
it('FE-RANGESTRIP-001: with no range the figure is a dash and no unit hangs off it', () => {
// A lone "km" beside a dash reads as a unit waiting for a number, which invites the
// guess that the number is zero. There is no number, so there is nothing to label.
render(strip({ rangeKm: null }))
expect(screen.getByText('—')).toBeInTheDocument()
expect(screen.queryByText('km')).not.toBeInTheDocument()
expect(screen.getByText('No range set')).toBeInTheDocument()
expect(screen.getByText(/Type a range/)).toBeInTheDocument()
})
it('FE-RANGESTRIP-002: with no range the bar reads out the hint alone', () => {
// The bar is drawn empty here, so its label is the only thing that can say why.
render(strip({ rangeKm: null }))
expect(screen.getByRole('img')).toHaveAccessibleName(
'Type a range, or fill in the figures below and TREK works it out.',
)
})
it('FE-RANGESTRIP-003: the figure and its unit are split but say the same thing', () => {
// Split so the unit can sit smaller beside the number; the split is only safe while
// the two halves still read as one reading of one distance.
render(strip({ rangeKm: 500 }))
expect(screen.getByText('500')).toBeInTheDocument()
expect(screen.getByText('km')).toBeInTheDocument()
})
it('FE-RANGESTRIP-004: an electric car charges where a petrol one fills', () => {
const { rerender } = render(strip({ electric: true }))
expect(screen.getByText('per charge')).toBeInTheDocument()
rerender(strip({ electric: false }))
expect(screen.getByText('per tank')).toBeInTheDocument()
})
it('FE-RANGESTRIP-005: a countable range is cut into blocks and says what one is worth', () => {
// Ten blocks. The chip is the only thing that turns the gaps from texture into a
// measure, so it has to appear with the mask rather than merely near it.
render(strip({ rangeKm: 500 }))
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
})
it('FE-RANGESTRIP-006: a range too short or too long to count is left solid', () => {
// Two blocks read as damage; forty eat the bar. Either way there is nothing to
// count, so claiming a block is worth fifty kilometres would be claiming a mask
// that was never cut.
const { rerender } = render(strip({ rangeKm: 100 }))
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
rerender(strip({ rangeKm: 2000 }))
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
})
it('FE-RANGESTRIP-007: the countable band has hard edges at three and thirty blocks', () => {
// Rounded, not floored: 130 km is three blocks and 1520 km is thirty. The pairs
// either side of each edge are what a change to that rounding would move.
const { rerender } = render(strip({ rangeKm: 120 }))
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
rerender(strip({ rangeKm: 130 }))
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
rerender(strip({ rangeKm: 1520 }))
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
rerender(strip({ rangeKm: 1560 }))
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
})
it('FE-RANGESTRIP-008: a partial fill says what that stop actually buys', () => {
// Four fifths of a 500 km tank. The whole point of the panel is that nobody charges
// to the top, so the note has to move off the full-tank sentence.
render(strip({ rangeKm: 500, fillPercent: 80 }))
expect(screen.getByText('A 80 % stop gives 400 km')).toBeInTheDocument()
expect(screen.queryByText('A stop fills right up')).not.toBeInTheDocument()
})
it('FE-RANGESTRIP-009: a full, an absent and an impossible fill all read as filling right up', () => {
// Anything outside nought to a hundred is not a fraction of a tank, and a note
// saying a 130 % stop gives 650 km would be the panel inventing range.
const { rerender } = render(strip({ fillPercent: 100 }))
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
rerender(strip({ fillPercent: null }))
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
rerender(strip({ fillPercent: 130 }))
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
// The other end of the same guard, and the one a cleared or mistyped field really
// produces: a stop that puts nothing in would leave the bar empty and the note
// claiming a range of nought.
rerender(strip({ fillPercent: 0 }))
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
rerender(strip({ fillPercent: -20 }))
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
})
it('FE-RANGESTRIP-010: wear is clamped to 90 rather than drawn past the bar', () => {
// A battery reported as 150 % gone would leave the middle segment at a negative
// width, which the browser drops: the tail would then be the whole bar and the chip
// would be telling the traveller the car cannot move.
render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 150 }))
expect(screen.getByText('90 % lost to age')).toBeInTheDocument()
expect(segments()).toEqual([8, 2, 90])
})
it('FE-RANGESTRIP-011: with nothing lost to age there is no tail to explain', () => {
// Zero is the ordinary case for a new car and for every petrol one. A chip reading
// "0 % lost to age" is an answer to a question nobody asked.
const { rerender } = render(strip({ wearPercent: 0 }))
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
rerender(strip({ wearPercent: -20 }))
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
expect(segments()).toEqual([100, 0, 0])
})
it('FE-RANGESTRIP-012: without a range there is no wear to take off it', () => {
// Wear is a share of a range. With no range the share is of nothing, and a tail on
// an empty bar would be the one filled thing on it.
render(strip({ rangeKm: null, wearPercent: 30 }))
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
expect(segments()).toEqual([0, 100, 0])
})
it('FE-RANGESTRIP-013: the three segments cover the bar exactly', () => {
// A fifth gone and a four-fifths stop: the fill is four fifths of what is LEFT, not
// of the factory bar, or the filled part would reach into the hatched tail.
render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 20 }))
expect(segments()).toEqual([64, 16, 20])
})
it('FE-RANGESTRIP-016: the stencil itself cuts one block per fifty kilometres', () => {
// Asserted on the function, because jsdom implements no mask property: React writes
// neither maskImage nor WebkitMaskImage and the element arrives with no style at
// all, so through the DOM every one of these cases would pass on an empty bar.
expect(blockMask(500)).toContain('repeating-linear-gradient')
// Ten blocks of fifty, so one keep-then-cut step is a tenth of the bar.
expect(blockMask(500)).toContain('10%')
// Five blocks, a fifth each: the step really follows the distance rather than being
// a fixed number of divisions dressed up as one.
expect(blockMask(250)).toContain('20%')
// Below three the cuts read as damage and above thirty the gaps eat the bar, so
// outside that the bar is honestly solid.
expect(blockMask(100)).toBeUndefined()
expect(blockMask(2000)).toBeUndefined()
expect(blockMask(null)).toBeUndefined()
})
it('FE-RANGESTRIP-017: the wear chip carries the same hatching as the tail of the bar', () => {
// The swatch is what ties the sentence to the stripe. Without it the hatching is an
// unexplained texture at the end of the bar and the chip an unexplained subtraction.
const { container } = render(strip({ rangeKm: 400, wearPercent: 12, electric: true }))
const hatched = Array.from(container.querySelectorAll<HTMLElement>('span[style*="repeating-linear-gradient"]'))
expect(hatched).toHaveLength(1)
expect(hatched[0].style.backgroundImage).toBe(
(screen.getByRole('img').firstElementChild?.lastElementChild as HTMLElement).style.backgroundImage,
)
})
it('FE-RANGESTRIP-014: the bar reads out the figure and the note together', () => {
// Everything else in here is colour and width. A screen reader gets the range from
// this label or not at all, so it has to carry both halves of the answer.
render(strip({ rangeKm: 500, fillPercent: 80 }))
expect(screen.getByRole('img')).toHaveAccessibleName('500 km — A 80 % stop gives 400 km')
})
it('FE-RANGESTRIP-015: imperial converts the figure, the unit and the block chip together', () => {
// The blocks are cut in kilometres whatever the setting, so the chip is the one
// place the two systems can drift apart: 50 km of bar has to be stated as 31.1 mi.
render(strip({ rangeKm: 500, unit: 'imperial' as DistanceUnit }))
expect(screen.getByText('310.7')).toBeInTheDocument()
expect(screen.getByText('mi')).toBeInTheDocument()
expect(screen.getByText('One block = 31.1 mi')).toBeInTheDocument()
})
})
@@ -0,0 +1,161 @@
import React from 'react'
import { useTranslation } from '../../i18n/TranslationContext'
import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../utils/units'
import type { DistanceUnit } from '../../types'
import { FS } from './typeScale'
/**
* How far the car goes on one fill, drawn as the tank it leaves the factory with.
*
* The figure above it is the answer; this is the argument that the figure is right. It
* carries the two things a bare number cannot say on its own:
*
* - The filled part is what a stop actually puts back in. Nobody charges to 100 % on the
* road, and a traveller who set 80 % is looking at four fifths of a bar, which is the
* honest picture of what the next leg has.
* - The hatched tail is what age has taken off an electric car's battery. It is already
* out of the number above, so without the tail that figure looks like an unexplained
* disagreement with the car's own data sheet.
*
* Widths animate because this sits directly under the fields that feed it: typing a
* consumption and watching the bar move is what makes the two ways of stating a range
* visibly the same thing.
*
* No box, no border, no tinted panel. It used to sit in a card of its own, which made it
* a second surface inside a dialog whose whole point is one plain column — and the box
* was doing nothing the bar was not already doing better.
*/
/**
* One reading of the figure above it.
*
* `bg-surface-secondary` rather than the card's own colour: these sit ON the card, so the
* chip has to be a step away from it in whichever scheme is on, and a white literal would
* be a step the wrong way in the dark one.
*/
const NOTE_CHIP = 'inline-flex items-center rounded-lg bg-surface-secondary px-2 py-1 text-caption leading-snug text-content-secondary'
/** How many kilometres one block of the bar stands for. */
const BLOCK_KM = 50
/**
* The bar, cut into blocks of a fixed distance rather than into quarters.
*
* Quarters divide a bar; blocks measure a road. With one block per fifty kilometres the
* bar for a 240 km car is visibly shorter than the bar for a 600 km one, which is the
* comparison somebody typing a consumption is actually making — and it turns the bar into
* something countable rather than something to eyeball.
*
* Cut as a MASK rather than painted over: the old quarter marks were drawn in the panel's
* own colour to look like gaps, which tied them to that panel and left them visible as
* stripes anywhere else. A mask makes real holes, so whatever is behind the bar shows
* through and nothing has to know the background.
*
* Exported for its own test rather than checked through the bar: jsdom supports no mask
* property at all, so React writes neither of the two and the element reaches a test
* carrying no style whatsoever. Through the DOM the rule is unobservable.
*/
export function blockMask(rangeKm: number | null): string | undefined {
if (!rangeKm) return undefined
const blocks = Math.round(rangeKm / BLOCK_KM)
// Below three the cuts read as damage rather than as measure, and above about thirty
// the gaps eat the bar. Outside that the bar is simply solid, which is honest: at that
// range the blocks were never going to be counted.
if (blocks < 3 || blocks > 30) return undefined
const step = 100 / blocks
// Straight, not slanted. The slant was tried and read as a decorative texture rather
// than as a measure: on a bar this thin the lean is legible only as fuzz on the seams.
//
// The colour is opacity here, not paint: black is "keep", transparent is "cut".
return `repeating-linear-gradient(90deg, #000 0 calc(${step}% - 2px), transparent calc(${step}% - 2px) ${step}%)` // theme-lint-disable — mask stencil, not a colour
}
export default function RangeStrip({ rangeKm, fillPercent, wearPercent, unit, electric }: {
rangeKm: number | null
fillPercent: number | null
wearPercent: number | null
unit: DistanceUnit
electric: boolean
}): React.ReactElement {
const { t } = useTranslation()
// Capped where the arithmetic caps it, so the picture cannot claim a battery is gone.
const wear = rangeKm ? Math.min(90, Math.max(0, wearPercent ?? 0)) : 0
const fill = fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : 100
const usable = 100 - wear
const filled = rangeKm ? (usable * fill) / 100 : 0
const rest = 100 - wear - filled
// Split rather than formatted whole, so the unit can sit smaller beside the figure. The
// rounding is formatDistance's own, because the badge on the trigger uses that and the
// two must not disagree by a tenth.
const shown = rangeKm ? Math.round(convertDistance(rangeKm, unit) * 10) / 10 : null
const stripes = 'repeating-linear-gradient(135deg, var(--border-primary) 0 3px, transparent 3px 6px)'
const mask = blockMask(rangeKm)
const note = shown === null
? t('roadtrip.limit.rangeEmptyHint')
: fill < 100
? t('roadtrip.limit.afterFill', { percent: fill, distance: formatDistance((rangeKm! * fill) / 100, unit) })
: t('roadtrip.limit.fullNote')
return (
<div className="flex flex-col gap-2 rounded-xl border border-edge-faint bg-surface-card px-3 py-2.5">
{/* The figure and what it is a figure OF, on one baseline. Wrapping rather than
truncating: "per charge" is three words in English and six in Hungarian. */}
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<span className="flex items-baseline gap-1">
<span className="font-semibold tabular-nums leading-none text-content" style={{ fontSize: FS.total }}>
{shown ?? '—'}
</span>
{shown !== null ? (
<span className="font-semibold leading-none text-content-muted" style={{ fontSize: FS.totalUnit }}>
{getDistanceUnitLabel(unit)}
</span>
) : null}
</span>
<span className="text-caption text-content-faint">
{shown === null
? t('roadtrip.limit.rangeEmpty')
: t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')}
</span>
</div>
<div
className="h-2.5 w-full overflow-hidden rounded-full bg-surface"
role="img"
aria-label={shown === null ? note : `${shown} ${getDistanceUnitLabel(unit)} — ${note}`}
>
<div className="flex h-full w-full" style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined} aria-hidden>
<div
className="h-full transition-[width] duration-500 ease-out"
style={{ width: `${filled}%`, background: 'linear-gradient(90deg, var(--accent-hover), var(--accent))' }}
/>
<div className="h-full transition-[width] duration-500 ease-out" style={{ width: `${rest}%` }} />
<div
className="h-full transition-[width] duration-500 ease-out"
style={{ width: `${wear}%`, backgroundImage: stripes }}
/>
</div>
</div>
{/* Three readings of this one figure — what a stop actually puts in, what one block
on the bar stands for, what age has taken off — each on its own chip inside the
card the number and the bar already share. As prose on one line they ran into
each other; given the card to themselves they looked like four separate answers.
Chips inside the card are both: three facts, one answer. */}
<div className="flex flex-wrap items-center gap-1.5">
<span className={NOTE_CHIP}>{note}</span>
{mask ? (
<span className={NOTE_CHIP}>{t('roadtrip.limit.blockNote', { distance: formatDistance(BLOCK_KM, unit) })}</span>
) : null}
{/* The swatch is what ties the chip to the tail of the bar. Without it the
hatching is an unexplained stripe and the sentence an unexplained loss. */}
{wear > 0 ? (
<span className={`${NOTE_CHIP} gap-1.5`}>
<span className="h-2 w-3.5 shrink-0 rounded-sm" style={{ backgroundImage: stripes }} aria-hidden />
{t('roadtrip.limit.wearNote', { percent: wear })}
</span>
) : null}
</div>
</div>
)
}
@@ -11,6 +11,8 @@ const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</Trans
const stop = (id: number, name: string): RoadtripStop => ({
assignmentId: id,
ownerDayId: 1,
ownerIndex: id - 1,
placeId: id * 10,
name,
lat: 53.5,
@@ -41,7 +43,7 @@ const day = (dayId: number, dayNumber: number): RoadtripDay => ({
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days,
lines: [],
lines: [], lineDays: [],
accessLines: [],
vias: [],
segments: [],
@@ -202,9 +204,9 @@ describe('RoadtripCorridorPanel', () => {
})
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)!
// "Fuel" also names an option in the kind picker; the group header is the one that
// folds its list, so it is the button.
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('button')).find(Boolean)!
expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument()
expect(screen.getByText('Camping Elbe')).toBeInTheDocument()
})
@@ -291,11 +293,17 @@ describe('RoadtripCorridorPanel', () => {
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', () => {
it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is not reported as a fault', () => {
// It used to say so, and it said so on nearly every search of a long day: the ceiling
// is per box and a busy corridor reaches it as a matter of course. That made a
// permanent complaint about a search that had worked, and its advice — narrow the
// corridor — makes the answer smaller rather than better. The warnings that remain
// are the ones about a search that did not happen.
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()
expect(screen.queryByText(/had more than fits in one answer/)).not.toBeInTheDocument()
expect(screen.getByText('Aral')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => {
@@ -1,6 +1,6 @@
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import {
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin,
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown,
} from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
@@ -85,6 +85,14 @@ function ResultBadge({ category }: { category: string }): React.ReactElement {
)
}
/**
* One fact about a hit, on its own surface.
*
* `bg-surface-secondary` rather than a literal: the row lifts to `surface-hover` under the
* pointer, and a chip painted white would stop lifting with it in the dark scheme.
*/
const POI_CHIP = 'inline-flex items-center rounded-md bg-surface-secondary px-1.5 py-0.5 leading-none tabular-nums text-content-muted'
function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
@@ -95,16 +103,16 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
<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>
{/* Two facts, two chips. Where it is off the road and how far into the drive it
comes are separate answers, and as two phrases sharing a line they read as one
run-on sentence about the same thing. A chip each gives them an edge, and the
row still wraps cleanly at a narrow width because nothing has to break around
a separator. */}
<div className="flex flex-wrap items-center gap-1" style={{ fontSize: FS.meta }}>
<span className={POI_CHIP}>
{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
</span>
<span className={POI_CHIP}>
{poi.alongKm < 0.5
? t('roadtrip.poi.atStart')
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
@@ -167,9 +175,30 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
const meta = CATEGORY_META[category]
const Icon = meta?.Icon ?? MapPin
const color = serviceColor(category)
/**
* Folded away, per category.
*
* A search for fuel AND food comes back as two lists that push each other off the
* screen, and the one being read is never the one at the top. Local state on purpose:
* which group somebody has open right now is a way of looking, not a setting — it
* should not outlive the search that produced these groups.
*/
const [open, setOpen] = useState(true)
const label = meta ? t(meta.labelKey) : category
return (
<section className="pb-1">
<header className="flex items-center gap-2.5 px-2 pb-1.5 pt-3">
// The gap between two groups belongs to the section, not to the strip: as padding on
// the strip it became part of the hover surface, which then hung well above the row
// it highlights.
<section className="pb-1 pt-1.5 first:pt-0">
{/* The whole strip is the control, not a chevron at its end: it is what the eye is
on when it decides this is not the list it wants, and at this width a 12px
target in the corner is a miss waiting to happen. */}
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
className="group/cat flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
>
<span
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
// theme-lint-disable — see ResultBadge.
@@ -178,13 +207,21 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
<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}
{label}
</span>
<span className={`ms-auto ${EYEBROW} tabular-nums`} style={{ fontSize: FS.micro }}>
{t('roadtrip.poi.found', { count: pois.length })}
</span>
</header>
<ul>
{/* Quiet until the strip is under the pointer, then it says which way this goes.
A chevron that is always at full contrast reads as a fourth piece of data in a
header that already carries three. */}
<ChevronDown
size={13}
className={`shrink-0 text-content-faint transition-all group-hover/cat:text-content ${open ? '' : '-rotate-90'}`}
aria-hidden
/>
</button>
<ul hidden={!open}>
{pois.map(poi => (
<ResultRow
key={poi.osm_id}
@@ -267,9 +304,11 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
// 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 })])
// A stretch that answered short is deliberately NOT reported. It fired on almost every
// search of a long day — the ceiling is per box and a busy corridor reaches it easily —
// so it read as a permanent complaint about a search that had in fact worked, and the
// advice it gave ("narrow the corridor") makes the answer smaller rather than better.
// The two warnings that remain are about a search that did not happen.
return (
<div className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
@@ -328,12 +367,16 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
{/* 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. */}
same search; repeating that on the button only made it small.
Its height is the controls' height, not a size of its own. At 38px it stood a
head above the segmented rows it follows and read as a second panel rather
than as the end of this one. */}
<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"
className="flex h-[32px] w-full items-center justify-center gap-2 rounded-lg bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
>
{search.loading
? <RotateCw size={15} className="animate-spin" aria-hidden />
@@ -509,7 +552,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
scene={search.loading ? 'search' : 'idle'}
mood={filteredToNothing ? 'confused' : undefined}
size={88}
compact
fill
surface="var(--bg-secondary)"
title={
search.loading
@@ -5,7 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import RoadtripLimitsCard from './RoadtripLimitsCard'
/**
* FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning.
* FE-ROADTRIP-LIMITS-001..012 — the 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
@@ -13,14 +13,26 @@ import RoadtripLimitsCard from './RoadtripLimitsCard'
* tenth of what was typed and only say so after the next reload.
*/
function open(onSave?: (key: string, value: number) => void) {
function open(onSave?: (key: string, value: number | string | boolean) => void) {
render(<RoadtripLimitsCard onSave={onSave} />)
fireEvent.click(screen.getByRole('button'))
}
/** The three inputs, in the order the dialog lists them. */
/** Every number field, in the order the dialog lists them. */
const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
/**
* Unfolds the car's own figures.
*
* They sit behind a disclosure now, open from the start only when one of them is already
* stored — so a test that arrives with an empty vehicle has to ask for them, and one that
* arrives with values does not.
*/
function openSpec() {
const toggle = screen.queryByRole('button', { name: /Work it out from the car/i })
if (toggle && toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle)
}
beforeEach(() => {
useSettingsStore.setState({
settings: {
@@ -127,4 +139,125 @@ describe('RoadtripLimitsCard', () => {
// handler to call, and must not assume one.
expect(() => fireEvent.blur(leg)).not.toThrow()
})
it('FE-ROADTRIP-LIMITS-009: naming the kind of car brings out its own figures', () => {
useSettingsStore.setState({
settings: { roadtrip_vehicle: 'electric', distance_unit: 'metric' } as never,
})
open(vi.fn())
// Four are always out: leg, day, range, fill. The car's own three are folded away
// until asked for, because nothing is stored on this vehicle yet.
expect(inputs()).toHaveLength(4)
openSpec()
// Leg, day, range, fill, battery, consumption, wear. The three at the end do not
// exist until there is a kind of car to have them.
expect(inputs()).toHaveLength(7)
expect(screen.getByText('kWh')).toBeInTheDocument()
expect(screen.getByText('kWh/100 km')).toBeInTheDocument()
})
it('FE-ROADTRIP-LIMITS-010: a complete pair takes the range field over', () => {
useSettingsStore.setState({
settings: {
roadtrip_vehicle: 'electric',
roadtrip_battery_kwh: 58,
roadtrip_kwh_per_100: 16,
// Deliberately different from what the figures work out to: the point of
// the row going read-only is that there is no longer a second answer.
roadtrip_range_km: 500,
distance_unit: 'metric',
} as never,
})
open(vi.fn())
expect(screen.getByTestId('limit-derived')).toHaveTextContent('363')
// Leg, day, battery, consumption, wear, fill — the range input is gone.
expect(inputs()).toHaveLength(6)
})
it('FE-ROADTRIP-LIMITS-011: gallons typed by an imperial reader are stored as litres', () => {
useSettingsStore.setState({
settings: { roadtrip_vehicle: 'combustion', distance_unit: 'imperial' } as never,
})
const onSave = vi.fn()
open(onSave)
// Leg, day, tank, consumption, range, fill.
openSpec()
const tank = screen.getByTestId('limit-tankLitres') as HTMLInputElement
fireEvent.change(tank, { target: { value: '15' } })
fireEvent.blur(tank)
// 15 US gallons, not 15 litres. Storing the number as typed would have made
// every fuel warning fire at a quarter of the right distance.
expect(onSave).toHaveBeenCalledWith('roadtrip_tank_litres', 56.78)
})
it('FE-ROADTRIP-LIMITS-012: the range answers while the figure is still being typed', () => {
useSettingsStore.setState({
settings: {
roadtrip_vehicle: 'electric',
roadtrip_battery_kwh: 58,
roadtrip_kwh_per_100: 16,
distance_unit: 'metric',
} as never,
})
const onSave = vi.fn()
open(onSave)
openSpec()
// By name rather than by position: the rows are no longer at fixed indices now that
// the car's figures live behind a disclosure.
const consumption = screen.getByTestId('limit-kwhPer100') as HTMLInputElement
fireEvent.change(consumption, { target: { value: '20' } })
// 58 kWh at 20 per hundred is 290 km, shown before anything is written: the gauge
// is the argument that these figures ARE the range, and it only makes it if it
// answers now rather than after the field is left.
expect(screen.getAllByText('290').length).toBeGreaterThan(0)
expect(onSave).not.toHaveBeenCalled()
})
/**
* The two switches over the route line itself.
*
* Both are off by default and both change what the map draws, so what is asserted is
* that each reports its own key and that neither is written until it is pressed.
*/
describe('the route line', () => {
it('FE-ROADTRIP-LIMITS-013: joining the days up is off until it is asked for', () => {
const onSave = vi.fn()
open(onSave)
const toggle = screen.getByRole('button', { name: 'Connect the days' })
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
// Costs a routing request per join and changes every day's kilometres, so it is a
// decision rather than a default.
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', true)
})
it('FE-ROADTRIP-LIMITS-014: a colour per day is its own switch', () => {
const onSave = vi.fn()
open(onSave)
fireEvent.click(screen.getByRole('button', { name: 'A colour per day' }))
expect(onSave).toHaveBeenCalledWith('roadtrip_day_colors', true)
expect(onSave).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-LIMITS-015: a switch that is on reports the way back off', () => {
useSettingsStore.setState({
settings: { roadtrip_connect_days: true, distance_unit: 'metric' } as never,
})
const onSave = vi.fn()
open(onSave)
const toggle = screen.getByRole('button', { name: 'Connect the days' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(toggle)
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false)
})
})
})
@@ -1,28 +1,38 @@
import React, { useState } from 'react'
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
import {
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship,
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
} 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 { formatDurationShort, parseAvoid, serializeAvoid, type VehicleKind } from './roadtripModel'
import { effectiveRangeKm, rangeFromSpec, showSpec, storeSpec, specUnit, type SpecKey, type VehicleSpec } from './vehicleRange'
import RangeStrip from './RangeStrip'
import { valhallaAvailable } from '../Map/valhallaRoute'
import ToggleSwitch from '../Settings/ToggleSwitch'
import type { DistanceUnit, RouteAvoidClass } from '../../types'
import { FS } from './typeScale'
/**
* The three numbers that decide when the rail speaks up about the driving.
* Everything that decides 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
* read never: a permanent card costs 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.
* the current answers, 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.
* All of it is personal rather than instance configuration: how long somebody is willing
* to sit behind a wheel, and what their car is, 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.
* The dialog has two halves because the settings are two kinds. The left is the trip: how
* long a day may be and what the road may not be. The right is the car, and it is the
* half that earns the width — a range can be typed as one number or worked out from what
* the vehicle is made of, and the gauge shows the second turning into the first as it is
* typed. Nothing here asks the router about a vehicle; OSM has no idea what car this is,
* and neither has TREK.
*/
/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */
@@ -31,12 +41,38 @@ function parseLimit(raw: string): number {
return Number.isFinite(n) && n > 0 ? n : 0
}
function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
/**
* One number in the form, or one number the form worked out.
*
* `derived` turns the row read-only and states where the figure came from. The row keeps
* its shape either way, because a range that switched between an input and a line of text
* would move every row under it the moment a consumption was typed.
*/
function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, testId, onDraft, onChange }: {
icon: typeof Clock
label: string
suffix: string
value: number | undefined
placeholder: string
step?: number
derived?: string
/**
* A stable handle for the tests, because the rows are no longer at fixed indices.
*
* The vehicle figures now live behind a disclosure, so "the fourth number field" means
* different things depending on whether it is open.
*/
testId?: string
/**
* What is in the field right now, on every keystroke, saved or not.
*
* Separate from `onChange` because the two answer different questions. `onChange` is
* "this is the new setting", which must stay rare — it is an HTTP write. This one is
* "this is what the traveller is looking at", which the gauge needs on every keystroke
* or it would sit still until the field is left and the promise it makes, that these
* figures are the range, would only pay out after the fact.
*/
onDraft?: (next: number) => void
onChange: (next: number) => void
}): React.ReactElement {
// Typed here, saved on blur or Enter. Writing on every keystroke sent one
@@ -54,59 +90,279 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
}
return (
<label className="flex items-center gap-3">
<label className="flex min-h-9 items-center gap-4">
<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 and unit in one wrapping flow, not in two columns. The unit used to have a
column of its own, sized per section, because "kWh/100 km" does not fit where
"min" does — and that column was the reason every section had a right edge of
its own. In the flow it simply wraps under a long label and costs nothing. */}
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-body text-content-secondary">{label}</span>
{/* The unit as a chip rather than as more grey words after the label. "kWh/100 km"
set as prose reads as part of the question; on a chip it reads as what the
number beside it will be measured in, which is what it is. */}
<span className="inline-flex items-center rounded-md bg-surface-tertiary px-1.5 py-0.5 text-caption leading-none text-content-muted">
{suffix}
</span>
</span>
{/* The one right edge of the whole dialog. Sized from the type rather than in fixed
pixels, so it grows with the reader's text-size setting instead of clipping: six
digits of tabular figures plus the field's own padding, and never narrower than
a switch. Geometry, which the styleguide allows inline. */}
<span className="flex shrink-0 justify-end" style={{ width: 'max(5.5rem, calc(6ch + 1.75rem))' }}>
{derived ? (
// A mark rather than a word: "computed" is eight characters in English and
// fourteen in half the locales. The sentence lives in the title and in the
// screen-reader text.
<span className="flex items-center gap-1.5" title={derived} data-testid="limit-derived">
<Sparkles size={12} className="shrink-0 text-accent-on" aria-hidden />
<span className="text-body font-semibold tabular-nums text-content">{value ?? 0}</span>
<span className="sr-only">{derived}</span>
</span>
) : (
<input
type="number"
inputMode="decimal"
min={0}
step={step ?? 1}
value={shown}
placeholder={placeholder}
aria-label={`${label}, ${suffix}`}
data-testid={testId}
onChange={e => { setDraft(e.target.value); onDraft?.(parseLimit(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() } }}
// The spinners are the other half of the width problem: they eat a third of a
// narrow field and nobody sets a driving limit by clicking an arrow.
className="w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-end text-body tabular-nums text-content [appearance:textfield] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
)}
</span>
</label>
)
}
/**
* One class the drive should leave out, with the switch that asks for it.
*
* Disabled rather than hidden when there is no engine that can answer: an operator who
* pointed the instance at their own OSRM has no second engine, and a switch that flips
* and changes nothing is worse than one that says why it cannot.
*/
function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
icon: typeof Coins
label: string
on: boolean
disabled: boolean
onToggle: () => void
}): React.ReactElement {
return (
<div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}>
<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>
{disabled
? <span className="text-caption text-content-faint">{'—'}</span>
: <ToggleSwitch on={on} onToggle={onToggle} label={label} />}
</div>
)
}
/**
* One section of the dialog, as a panel of its own.
*
* The complaint the redesign started from was "untidy", and the cause was that four
* sections sat in two columns with nothing between them but a heading: no edges, so the
* eye had to work out where one ended, and two right edges, so nothing lined up across
* the middle. A panel draws that boundary once and the rows inside inherit one rhythm.
*
* The closing note is part of the panel rather than a paragraph somebody remembers to add:
* every one of these sections has a sentence that has to be said (driving time is driving
* time only; avoidance is a weighting, not a ban), and left to each caller they drifted
* apart in spacing and in colour.
*/
function Panel({ icon: Icon, title, note, children }: {
icon: typeof Clock
title: string
note?: string
children: React.ReactNode
}): React.ReactElement {
return (
<section className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5">
<SectionTitle icon={Icon}>{title}</SectionTitle>
{children}
{note ? <p className="text-caption leading-snug text-content-faint">{note}</p> : null}
</section>
)
}
/** The name of one section, inside its panel. */
function SectionTitle({ icon: Icon, children }: {
icon: typeof Clock
children: React.ReactNode
}): React.ReactElement {
return (
<p className="flex items-center gap-2 text-caption font-semibold uppercase tracking-wide text-content-faint">
<Icon size={13} aria-hidden />
{children}
</p>
)
}
/** Not said, petrol, electric. "Not said" first, because it is what everybody starts on. */
const VEHICLES: { key: VehicleKind | null; labelKey: string; Icon: typeof Car }[] = [
{ key: null, labelKey: 'roadtrip.limit.vehicleAny', Icon: Car },
{ key: 'combustion', labelKey: 'roadtrip.limit.vehicleCombustion', Icon: Fuel },
{ key: 'electric', labelKey: 'roadtrip.limit.vehicleElectric', Icon: Zap },
]
/**
* What each kind of car is made of, and where each figure is kept.
*
* Two rows for petrol and three for electric, because that is the difference that
* matters: a tank does not lose capacity to the years and a battery does. Everything else
* ABRP asks for — plug type, reference speed, relative speed, drive style — shapes a
* consumption PREDICTION. This dialog does not predict one; it divides a capacity by a
* consumption the traveller states, so those fields would add typing without moving a
* single kilometre of the answer.
*/
const SPEC_ROWS: Record<VehicleKind, { key: SpecKey; setting: string; Icon: typeof Fuel; labelKey: string; step: number }[]> = {
combustion: [
{ key: 'tankLitres', setting: 'roadtrip_tank_litres', Icon: Fuel, labelKey: 'roadtrip.limit.tankLabel', step: 1 },
{ key: 'litresPer100', setting: 'roadtrip_litres_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 },
],
electric: [
{ key: 'batteryKwh', setting: 'roadtrip_battery_kwh', Icon: BatteryFull, labelKey: 'roadtrip.limit.batteryLabel', step: 1 },
{ key: 'kwhPer100', setting: 'roadtrip_kwh_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 },
{ key: 'degradationPercent', setting: 'roadtrip_battery_degradation', Icon: BatteryWarning, labelKey: 'roadtrip.limit.wearLabel', step: 1 },
],
}
/**
* The settings every one of the car's own figures is stored under.
*
* Read once, to decide whether the disclosure below the range starts open: a stored
* figure means the range row is derived, and its cause has to be in sight.
*/
const SPEC_SETTINGS = ['roadtrip_tank_litres', 'roadtrip_litres_per_100', 'roadtrip_battery_kwh', 'roadtrip_kwh_per_100', 'roadtrip_battery_degradation'] as const
export default function RoadtripLimitsCard({ onSave }: {
/** Persists one setting. Absent leaves the dialog read-only. */
onSave?: (key: string, value: number) => void
/**
* Persists one setting. Absent leaves the dialog read-only.
*
* A string as well as a number since the avoidance is stored as a comma list: one
* decision with three parts, which goes to the router as one request either way.
*/
onSave?: (key: string, value: number | string | boolean) => 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 imperial = distanceUnit === 'imperial'
const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes
const rangeKm = settings.roadtrip_range_km
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
// an unknown word here would become a costing option the router does not have.
const vehicle = settings.roadtrip_vehicle ?? ''
const vehicleKind: VehicleKind | null =
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
const saved: VehicleSpec = {
tankLitres: settings.roadtrip_tank_litres,
litresPer100: settings.roadtrip_litres_per_100,
batteryKwh: settings.roadtrip_battery_kwh,
kwhPer100: settings.roadtrip_kwh_per_100,
degradationPercent: settings.roadtrip_battery_degradation,
}
/**
* The one field being typed in, ahead of the write that will save it.
*
* One slot rather than a copy of the form, because only one field can be typed in at a
* time, and a second copy of the settings is exactly the hand-mirrored state this
* codebase refuses everywhere else. It exists so the gauge answers while the traveller
* is still typing: the figures below it are only convincingly "the range" if changing
* one moves the bar, and a settings PUT per keystroke is not on offer.
*
* The value arrives in whatever unit the field is labelled with, so it goes back
* through the same conversion the save would have used.
*/
const [preview, setPreview] = useState<{ key: SpecKey | 'range' | 'fill'; value: number } | null>(null)
/**
* Whether the car's own figures are unfolded.
*
* Open from the start the moment any of them is stored, because then the range row is
* read-only and folding its cause away would leave a field nobody can edit with no
* visible reason. Read once at mount: re-deriving it would slam the panel shut under
* the hand of somebody clearing the last field.
*/
const [specOpen, setSpecOpen] = useState(() =>
SPEC_SETTINGS.some(k => typeof (settings as unknown as Record<string, unknown>)[k] === 'number'))
const spec: VehicleSpec = preview && preview.key !== 'range' && preview.key !== 'fill'
? { ...saved, [preview.key]: storeSpec(preview.key, preview.value, imperial) }
: saved
const typedKm = preview?.key === 'range'
? (imperial ? preview.value / 0.621371 : preview.value)
: rangeKm
const shownFill = preview?.key === 'fill' ? preview.value : settings.roadtrip_fill_percent ?? null
// What the parts say, and what the trip will actually plan with. The second is the
// first when there is one, which is the rule effectiveRangeKm states once for
// everybody who needs it.
const computedKm = rangeFromSpec(vehicleKind, spec)
const planningKm = effectiveRangeKm(vehicleKind, spec, typedKm)
const avoiding = parseAvoid(settings.roadtrip_avoid)
// No second engine, no avoidance. An instance pointed at its own OSRM has one, and its
// car profile is built without excludable classes on every public host.
const canAvoid = valhallaAvailable()
const toggleAvoid = (cls: RouteAvoidClass) => {
const next = avoiding.includes(cls) ? avoiding.filter(c => c !== cls) : [...avoiding, cls]
onSave?.('roadtrip_avoid', serializeAvoid(next))
}
const AVOID_ROWS: { cls: RouteAvoidClass; icon: typeof Coins; label: string }[] = [
{ cls: 'toll', icon: Coins, label: t('roadtrip.avoid.toll') },
{ cls: 'motorway', icon: Signpost, label: t('roadtrip.avoid.motorway') },
{ cls: 'ferry', icon: Ship, label: t('roadtrip.avoid.ferry') },
]
// 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
// third too early for ever after. The same rule runs through the vehicle's own figures,
// where showSpec and storeSpec do it per field.
const rangeShown = planningKm ? Math.round(convertDistance(planningKm, distanceUnit)) : undefined
const setRange = (shown: number) => {
const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown
const km = 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
// Each one keeps its own icon, because 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,
// The planning range, not the typed one: when the vehicle's figures win, the badge
// has to say what the warnings will actually use.
planningKm ? { key: 'range', Icon: vehicleKind === 'electric' ? Zap : Fuel, text: formatDistance(planningKm, distanceUnit) } : null,
// Only worth a badge when it is NOT a full tank, which is the case that changes the
// arithmetic. "100 %" on the trigger would be a badge for the default.
settings.roadtrip_fill_percent && settings.roadtrip_fill_percent < 100
? { key: 'fill', Icon: BatteryCharging, text: `${settings.roadtrip_fill_percent} %` }
: null,
// The avoidance rides here too, and as ONE badge rather than three: it is a single
// decision, the three icons already say which parts of it are on, and a fourth,
// fifth and sixth badge would wrap the row onto a second line in a narrow rail.
avoiding.length && canAvoid
? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) }
: null,
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
return (
@@ -120,7 +376,7 @@ export default function RoadtripLimitsCard({ onSave }: {
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
{/* No icon tile on the trigger: the 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">
@@ -156,43 +412,213 @@ export default function RoadtripLimitsCard({ onSave }: {
</button>
{open ? (
<Modal isOpen onClose={() => setOpen(false)} size="sm" title={
<Modal isOpen onClose={() => { setOpen(false); setPreview(null) }} size="4xl" 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>
{/* Two columns, kept. One column reads more tidily but turns the dialog into
something that has to be scrolled, and a setting you cannot see while you
change another is worse than a seam down the middle.
What answers the seam is not the count of columns but what is in them: each
section is now a panel of its own with a visible edge, every row inside it
ends on the same right edge, and the panels are stacked to one rhythm. The
left column is the trip, the right one is the car. */}
<div className="grid items-start gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-4">
<Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}>
<LimitRow
icon={Clock}
label={t('roadtrip.limit.legLabel')}
suffix={t('roadtrip.limit.minutes')}
value={legMinutes}
placeholder={t('roadtrip.limit.off')}
testId="limit-legMinutes"
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')}
testId="limit-dayMinutes"
onChange={v => onSave?.('roadtrip_day_minutes', v)}
/>
</Panel>
<Panel
icon={Signpost}
title={t('roadtrip.avoid.section')}
note={canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')}
>
{AVOID_ROWS.map(({ cls, icon, label }) => (
<AvoidRow
key={cls}
icon={icon}
label={label}
on={avoiding.includes(cls)}
disabled={!canAvoid}
onToggle={() => toggleAvoid(cls)}
/>
))}
</Panel>
<Panel icon={Route} title={t('roadtrip.line.section')} note={t('roadtrip.line.hint')}>
{/* The road between one day's last stop and the next day's first is real
driving that the rail has never drawn: days are routed one at a time,
so that gap was never asked for. Off by default because it costs a
routing request per join and changes every day's kilometres. */}
<AvoidRow
icon={Link2}
label={t('roadtrip.line.connect')}
on={!!settings.roadtrip_connect_days}
disabled={false}
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
/>
{/* Which matters most once the line IS continuous: end to end it is one
stroke, and a colour per day is what puts the days back into it. */}
<AvoidRow
icon={Palette}
label={t('roadtrip.line.dayColors')}
on={!!settings.roadtrip_day_colors}
disabled={false}
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
/>
</Panel>
</div>
<Panel
icon={Car}
title={t('roadtrip.limit.sectionVehicle')}
note={vehicleKind ? t('roadtrip.limit.specHint') : t('roadtrip.limit.vehicleHint')}
>
{/* First, because it decides what everything under it means: with no
vehicle named, a petrol station and a charger both fill the tank, which
is arithmetic that is wrong for everybody who drives just one of them.
Radios in a segmented shell rather than a dropdown: three options that
change the meaning of everything below them should be readable without
being opened, and the arrow keys, the names and the roles then come
from the browser instead of from a rebuilt listbox. */}
<fieldset className="min-w-0">
<legend className="sr-only">{t('roadtrip.limit.vehicleLabel')}</legend>
<div className="grid grid-cols-3 gap-1 rounded-xl border border-edge bg-surface-tertiary p-1">
{VEHICLES.map(({ key, labelKey, Icon }) => (
<label key={key ?? 'unset'} className="min-w-0">
<input
type="radio"
name="roadtrip-vehicle"
className="peer sr-only"
checked={(settings.roadtrip_vehicle || '') === (key ?? '')}
onChange={() => {
// Never writes the car's figures away: switching kind leaves
// both sets stored, `rangeFromSpec` simply returns null for
// the other one, and the typed range comes back as an editable
// field. Deleting the other kind's values here would lose data
// on a mis-click.
setPreview(null)
onSave?.('roadtrip_vehicle', key ?? '')
}}
/>
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent">
<Icon size={14} className="shrink-0" aria-hidden />
{t(labelKey)}
</span>
</label>
))}
</div>
</fieldset>
<RangeStrip
rangeKm={planningKm}
fillPercent={shownFill}
wearPercent={vehicleKind === 'electric' ? spec.degradationPercent ?? null : null}
unit={distanceUnit}
electric={vehicleKind === 'electric'}
/>
{/* Read-only once the parts add up, because then it IS the parts. Leaving
it editable would offer a second answer to a question that already has
one, and the traveller would have no way of telling which the warnings
were using. Clearing a field above hands the row straight back. */}
<LimitRow
icon={Route}
// "on one tank" is the wrong noun for half the travellers who set this.
label={t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel')}
suffix={imperial ? 'mi' : 'km'}
value={rangeShown}
placeholder={t('roadtrip.limit.off')}
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
testId="limit-range"
onDraft={v => setPreview({ key: 'range', value: v })}
onChange={setRange}
/>
{/* Under the range, because it is a fraction OF it. Nobody charges to
100 % on the road: the last fifth takes as long as the first four, so a
stop counted as a full tank overstates everything after it by that
fifth. */}
<LimitRow
icon={BatteryCharging}
label={t('roadtrip.limit.fillLabel')}
suffix="%"
value={settings.roadtrip_fill_percent}
placeholder={t('roadtrip.limit.fillFull')}
testId="limit-fill"
onDraft={v => setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
/>
{/* The car's own figures, folded away. Nothing here is required — somebody
who knows their range types it above and never opens this — but it
starts open the moment any of them is stored, because otherwise the
range row is read-only with its cause out of sight. */}
{vehicleKind ? (
<>
<button
type="button"
onClick={() => { setSpecOpen(o => !o); setPreview(null) }}
aria-expanded={specOpen}
aria-controls="roadtrip-spec"
className="-mx-1 flex items-center gap-1.5 self-start rounded-lg px-1 py-0.5 text-caption font-semibold text-accent-on transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<ChevronRight
size={13}
className={`shrink-0 transition-transform ${specOpen ? 'rotate-90' : ''}`}
aria-hidden
/>
{t('roadtrip.limit.specToggle')}
</button>
{/* `hidden` rather than unmounting: it really leaves the tab order,
and a field that kept a draft while folded away would be a draft
nobody can see. */}
{/* The display class only while it is open. `[hidden]` sets
`display: none` in the preflight, but a `flex` class beats it on
specificity — so the attribute flipped, the semantics were right,
and the panel stayed on screen. */}
<div id="roadtrip-spec" hidden={!specOpen} className={specOpen ? 'flex flex-col gap-3' : ''}>
{SPEC_ROWS[vehicleKind].map(({ key, setting, Icon, labelKey, step }) => (
<LimitRow
key={key}
icon={Icon}
label={t(labelKey)}
suffix={specUnit(key, imperial)}
step={step}
value={showSpec(key, saved[key], imperial)}
placeholder={t('roadtrip.limit.off')}
testId={`limit-${key}`}
onDraft={v => setPreview({ key, value: v })}
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
/>
))}
</div>
</>
) : null}
</Panel>
</div>
</div>
</Modal>
) : null}
@@ -5,6 +5,9 @@ import { TranslationProvider } from '../../i18n'
import RoadtripSidebar from './RoadtripSidebar'
import RoadtripModeSwitch from './RoadtripModeSwitch'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { DryPoint } from './roadtripModel'
import type { RefuelSearch } from './useRefuelSearch'
import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion'
import type { RouteSegment } from '../../types'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
@@ -12,6 +15,11 @@ const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</Trans
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
return {
placeId: over.assignmentId * 10,
// Filled in by `day()` from the card it is put on, unless the case under test says
// otherwise: a stop names the day it is STORED on, which after a night drive is not
// the card it is drawn on. -1 is "not stated", never a real id.
ownerDayId: -1,
ownerIndex: -1,
lat: 53.5,
lng: 9.9,
time: null,
@@ -31,12 +39,12 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Berlin' }),
]
const dayId = over.dayId ?? 1
return {
dayId: 1,
dayId,
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: [] },
@@ -44,6 +52,13 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
distance: 100000,
duration: 3600,
...over,
// After the spread, because `over` carries the raw stops this was built from: the
// same pair the hook fills in, for every stop that did not state its own.
stops: stops.map((s, i) => ({
...s,
ownerDayId: s.ownerDayId === -1 ? dayId : s.ownerDayId,
ownerIndex: s.ownerIndex === -1 ? i : s.ownerIndex,
})),
}
}
@@ -51,6 +66,7 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
return {
days: [day()],
lines: [],
lineDays: [],
segments: [],
accessLines: [],
vias: [],
@@ -168,11 +184,14 @@ describe('RoadtripSidebar', () => {
expect(screen.getByText('Hamburg').closest('button')).not.toHaveAttribute('aria-current')
})
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive explains itself', () => {
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive shows the empty state, not a zeroed rail', () => {
// Nothing to total up, so no "0 km" standing over an empty list. The mascot
// is the shape every other empty state in TREK uses.
wrap(<RoadtripSidebar routes={routes({ days: [], totalDistance: 0, totalDuration: 0, totalStops: 0 })} />)
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
expect(screen.getByText('No route yet')).toBeInTheDocument()
expect(screen.queryByText('Hamburg')).not.toBeInTheDocument()
expect(screen.queryByText(/0 km/)).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-010: each day carries its own distance and time', () => {
@@ -198,11 +217,15 @@ describe('RoadtripSidebar', () => {
}
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.
// The crossing is no longer a band in the chain: a drive past midnight hands its
// stops to the next day's card, and the block around them there is what says so
// (`nightSpill.ts`). What is left on a day that could not hand them on — the last day
// of a trip, or one whose arrivals are supplied directly as they are here — is the
// marker on the arrival, where "01:30" would otherwise read as tonight.
// One mention, not two: the marker on the arrival, spelled out for a screen reader.
// The band that used to repeat it in the chain is gone — a drive past midnight hands
// its stops to the next day's card now, and the block around them there says it.
expect(screen.getAllByText('Next day')).toHaveLength(1)
expect(screen.getByText('01:30').textContent).toContain('+1')
})
@@ -221,7 +244,9 @@ describe('RoadtripSidebar', () => {
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
expect(screen.getAllByText('Next day')).toHaveLength(2)
// Both findings still reach the stop; only the band that used to repeat the crossing
// is gone. Reading just the first warning used to drop the late flag entirely.
expect(screen.getByText('01:30').textContent).toContain('+1')
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
})
@@ -299,6 +324,23 @@ describe('RoadtripSidebar', () => {
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
})
it('FE-ROADTRIP-SIDEBAR-034: a pause reached late says so, the same as a numbered stop', () => {
// The schedule restarts its chain at any pinned time, whatever kind of stop carries
// it, so it computes the finding for a fuel halt too. The rail used to hand `late`
// only to the numbered branch and throw the pause's own away.
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }),
]
const schedule = {
entries: stops.map(() => ({ arrival: '09:00', departure: '09:15', anchored: true, dayOffset: 0 })),
warnings: [{ index: 1, code: 'late' as const, minutes: 45 }],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule, legs: [leg()] })] })} />)
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
})
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 }),
@@ -537,6 +579,443 @@ describe('RoadtripSidebar', () => {
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
expect(screen.queryByRole('button', { name: 'Track' })).toBeNull()
})
/**
* The block a night drive leaves on the next morning's card.
*
* The stops are stored on the day they set off from and drawn on the day they are
* reached, so what is asserted here is that the block says where they came from and
* that the drive through the night is drawn with them. See `nightSpill.ts`.
*/
describe('a stretch driven onto this day through the night', () => {
const spilled = () => day({
dayNumber: 2,
stops: [
stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }),
stop({ assignmentId: 2, name: 'Wittenberg' }),
],
spills: [{
at: 0,
count: 1,
fromDayNumber: 1,
departure: '22:30',
leg: leg({ distance: 253000, duration: 10380 }),
line: [[53, 10], [52, 11]],
fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }),
}],
})
it('FE-ROADTRIP-SIDEBAR-031: names the day the stretch came from', () => {
wrap(<RoadtripSidebar routes={routes({ days: [spilled()] })} />)
expect(screen.getByText('From day 1')).toBeTruthy()
})
it('FE-ROADTRIP-SIDEBAR-032: draws the night drive, so the kilometres reach the stops', () => {
wrap(<RoadtripSidebar routes={routes({ days: [spilled()] })} />)
// Without it the card would gain a stop and none of the driving that reaches it.
expect(screen.getByText('253 km in 2 h 53 min')).toBeTruthy()
expect(screen.getByText('leaves 22:30')).toBeTruthy()
})
it('FE-ROADTRIP-SIDEBAR-030: a drag names the day the stop is STORED on, not the card', () => {
const onReorderStop = vi.fn()
const onMoveStopToDay = vi.fn()
const { container } = wrap(<RoadtripSidebar
routes={routes({ days: [spilled()] })}
onReorderStop={onReorderStop}
onMoveStopToDay={onMoveStopToDay}
/>)
// Dropping the borrowed stop onto its neighbour is a reorder inside day 9 — the day
// the server knows it by — even though both are drawn under day 2.
// The draggable rows only: the block around the borrowed stretch is a list item
// too, and it contains the text of every stop inside it.
const rows = container.querySelectorAll('li[draggable="true"]')
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
fireEvent.dragStart(rows[0], { dataTransfer })
fireEvent.dragOver(rows[1], { dataTransfer })
fireEvent.drop(rows[1], { dataTransfer })
// Dragged from day 9 index 3 — where the server has it — onto day 1 index 1, the
// stored position of the row it was dropped on. Neither number is the card's.
expect(onMoveStopToDay).toHaveBeenCalledWith(9, 1, 1, 1)
expect(onReorderStop).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-033: an ordinary day draws no block at all', () => {
wrap(<RoadtripSidebar routes={routes()} />)
expect(screen.queryByText(/From day/)).toBeNull()
})
})
/**
* The band where the fuel actually ends, and the one search offered from it.
*
* It hangs off the leg rather than off a stop, because those are two different places:
* the range warning marks where somebody finds out, and that can be a long way past the
* point the tank ran dry. A station offered at the warning is one the car cannot reach.
*/
describe('the leg the tank runs out on', () => {
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
osm_id: `osm-${name}`,
name,
lat: 52.4,
lng: 10.1,
category: 'fuel',
poi_type: 'fuel',
address: null,
website: null,
phone: null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap',
alongKm: 170,
offRouteKm,
spareKm,
})
const empties = dry({ legIndex: 1 })
// Three stops, so the leg that empties has one it is not on to be absent from.
const thirsty = (): RoadtripDay => day({
stops: [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Hannover' }),
stop({ assignmentId: 3, name: 'Kassel' }),
],
legs: [leg(), leg()],
dryPoints: [empties],
})
it('FE-ROADTRIP-SIDEBAR-035: the band sits on the leg the fuel ends on, and says how far into it', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [thirsty()], totalStops: 3 })} refuel={search()} />,
)
// The second row owns the second leg. Drawn on every row instead, the rail would
// offer a fill-up on a stretch the car drives with a full tank.
const rows = container.querySelectorAll('li')
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
// Distance into the leg, which is where a drive band keeps its figures. The 600 is
// the setting that was crossed and belongs to no place on the map.
expect(screen.getByText('after 182 km')).toBeInTheDocument()
expect(screen.queryByText(/600 km/)).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-036: the low-fuel lamp is itself the button that goes looking', () => {
const onAskRefuel = vi.fn()
wrap(
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search()}
onAskRefuel={onAskRefuel}
/>,
)
// Not a lamp with a magnifier beside it, which says the same thing twice: the lamp
// reports the empty tank and pressing it is what does something about it, so the
// lamp has to be inside the button rather than next to one.
const ask = screen.getByRole('button', { name: 'Find fuel' })
expect(ask.querySelector('svg.lucide-fuel')).toBeInTheDocument()
expect(within(ask).queryByRole('button')).toBeNull()
fireEvent.click(ask)
// The dry point travels with the ask: the search is a circle pulled back from that
// coordinate, and the day alone does not say where on it to look.
expect(onAskRefuel).toHaveBeenCalledWith(1, empties)
})
it('FE-ROADTRIP-SIDEBAR-037: a band inside a borrowed stretch is filed under the card it is drawn on', () => {
const onAskRefuel = vi.fn()
const overnight = dry({ legIndex: 0 })
const card = day({
dayNumber: 2,
stops: [
stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }),
stop({ assignmentId: 2, name: 'Wittenberg' }),
],
dryPoints: [overnight],
spills: [{
at: 0,
count: 1,
fromDayNumber: 1,
departure: '22:30',
leg: leg(),
line: [[53, 10], [52, 11]],
fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }),
}],
})
const { unmount } = wrap(
<RoadtripSidebar
routes={routes({ days: [card] })}
refuel={search({ openFor: '1:0', loading: true })}
onAskRefuel={onAskRefuel}
/>,
)
// Everything that WRITES names the day a stop is stored on, day 9 here. This is the
// exception and it has to be: the open search is filed under the card day and the
// leg, so a band asking as day 9 would have its answers arrive under a key no band
// is watching and would never open. That it opened at all is the assertion.
expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
unmount()
wrap(
<RoadtripSidebar
routes={routes({ days: [card] })}
refuel={search()}
onAskRefuel={onAskRefuel}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Find fuel' }))
expect(onAskRefuel).toHaveBeenCalledWith(1, overnight)
})
it('FE-ROADTRIP-SIDEBAR-038: while the request is in flight the lamp steps aside for the way out', () => {
const close = vi.fn()
wrap(
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search({ openFor: '1:1', loading: true, close })}
/>,
)
expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
// Gone, not merely quieter. Every press is a real request against a shared service,
// and a lamp still standing there invites a second one nobody asked for.
expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(close).toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-039: an answer carries the detour and what is left in the tank as two figures', () => {
const onAcceptRefuel = vi.fn()
const shell = pump('Shell Hannover', 1.4, 48.6)
wrap(
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search({ openFor: '1:1', outcome: 'found', results: [shell] })}
onAcceptRefuel={onAcceptRefuel}
/>,
)
// Two facts about two different things: the detour is what the stop costs and what
// the list is sorted by, the spare is what is left when the car draws level. Joined
// into one line with a separator, neither exact lookup finds anything.
expect(screen.getByText('1.4 km')).toBeInTheDocument()
expect(screen.getByText('49 km')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add Shell Hannover as a fuel stop' }))
// With the dry point, because the caller has no other way of knowing which leg the
// accepted stop belongs on.
expect(onAcceptRefuel).toHaveBeenCalledWith(1, shell, empties)
})
it('FE-ROADTRIP-SIDEBAR-040: a search that came back with nothing says which nothing it was', () => {
const answered = (outcome: RefuelOutcome) => (
<TranslationProvider>
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search({ openFor: '1:1', outcome })}
/>
</TranslationProvider>
)
const { rerender } = render(answered('none'))
// Only the first of these is a statement about the road. Telling somebody there is
// nothing on a stretch that was never fully checked, or never checked at all, is
// worse than saying nothing.
expect(screen.getByText(/Nothing found/)).toBeInTheDocument()
rerender(answered('incomplete'))
expect(screen.getByText(/cut short/)).toBeInTheDocument()
rerender(answered('failed'))
expect(screen.getByText(/did not answer/)).toBeInTheDocument()
// And a way to ask again rather than a dead end: the place search is a shared
// service that does time out, and no answer with no retry reads as broken.
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-041: the stop stops repeating what the band already says better', () => {
// The warning marks the stop somebody finds out at and counts the whole tank; the
// band sits where the fuel ends and offers a way out. Both at once prints the answer
// above the problem.
const warned = (): RoadtripDay => ({
...thirsty(),
driveWarnings: [{ index: 2, code: 'range', sinceKm: 640 }],
})
const { unmount } = wrap(
<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} refuel={search()} />,
)
expect(screen.queryByText('640 km')).toBeNull()
unmount()
// With no search to run there is nothing better, so the finding is all there is and
// has to stay.
wrap(<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} />)
expect(screen.getByText('640 km')).toBeInTheDocument()
})
})
/**
* Folding a day down to its header.
*
* The whole header is the control and the hover is the only sign of it: a chevron or a
* fold label sitting among the day's own facts reads as a fourth fact about the day
* rather than as something to press. Nothing on screen says which way it stands, so the
* header has to say it to a screen reader.
*/
describe('a day folded down to its header', () => {
it('FE-ROADTRIP-SIDEBAR-042: the header is the control, and it folds by id rather than by number', () => {
const onToggleDay = vi.fn()
// Id and number deliberately apart. The card is titled by its number and folded by
// its id, and on a trip whose days were not created in order those two differ —
// mixing them up folds a card the reader did not click.
wrap(<RoadtripSidebar routes={routes({ days: [day({ dayId: 42 })] })} onToggleDay={onToggleDay} />)
const header = screen.getByRole('button', { name: /Day 1/ })
expect(header).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(header)
expect(onToggleDay).toHaveBeenCalledWith(42)
})
it('FE-ROADTRIP-SIDEBAR-043: folded, the stops are gone and the header is what is left', () => {
// Folded by id, again with a card whose id is not its number: a set read as numbers
// would fold the card titled "Day 42" here, or nothing at all.
wrap(<RoadtripSidebar
routes={routes({ days: [day({ dayId: 42 }), day({ dayId: 2, dayNumber: 2 })], totalStops: 4 })}
onToggleDay={vi.fn()}
collapsedDayIds={new Set([42])}
/>)
const folded = screen.getByRole('button', { name: /Day 1/ })
expect(folded).toHaveAttribute('aria-expanded', 'false')
// The card keeps its head: a day put away is still one the reader scrolls past, and
// its own figures are how it is found again.
expect(within(folded).getByText('2 stops')).toBeInTheDocument()
expect(screen.getAllByText('Hamburg')[0]).not.toBeVisible()
// One card at a time. Folding is per day, and the id is what decides which.
expect(screen.getByRole('button', { name: /Day 2/ })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getAllByText('Berlin')[1]).toBeVisible()
})
it('FE-ROADTRIP-SIDEBAR-044: the header answers the keys that mean press, and no others', () => {
const onToggleDay = vi.fn()
wrap(<RoadtripSidebar routes={routes()} onToggleDay={onToggleDay} />)
const header = screen.getByRole('button', { name: /Day 1/ })
// A header wearing a button role gets none of a real button's keyboard behaviour, so
// it answers both keys itself. Space has to be swallowed as well as answered, or the
// rail scrolls a page down behind the day that was just put away. fireEvent reports
// a cancelled event as false.
fireEvent.keyDown(header, { key: 'Enter' })
expect(fireEvent.keyDown(header, { key: ' ' })).toBe(false)
expect(onToggleDay).toHaveBeenCalledTimes(2)
fireEvent.keyDown(header, { key: 'ArrowDown' })
expect(onToggleDay).toHaveBeenCalledTimes(2)
})
it('FE-ROADTRIP-SIDEBAR-045: a rail with nothing to fold offers no control at all', () => {
wrap(<RoadtripSidebar routes={routes()} />)
// A viewer gets a heading. A control that does nothing when pressed is worse than
// one that is not there.
expect(screen.queryByRole('button', { name: /Day 1/ })).toBeNull()
expect(screen.getByRole('heading', { level: 3 })).toBeVisible()
expect(screen.getByText('Hamburg')).toBeVisible()
})
})
/**
* How full one stop fills the tank.
*
* A property of the stop rather than of the traveller: the motorway rapid charger is
* worth 80 % because the last fifth costs as long again, the one at the hotel is worth
* all of it. The badge is the only way into that figure anywhere in TREK.
*/
describe('the fill badge on a stop that puts fuel back', () => {
const refuelling = (fillPercent?: number | null): RoadtripDay => day({
stops: [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', fillPercent }),
],
})
it('FE-ROADTRIP-SIDEBAR-046: the badge is the way into a figure that is not there yet', () => {
const onSelectStop = vi.fn()
const onSetStopFill = vi.fn()
wrap(<RoadtripSidebar
routes={routes({ days: [refuelling()] })}
onSelectStop={onSelectStop}
onSetStopFill={onSetStopFill}
/>)
// The plus stands in the slot the number will occupy: a badge that hid itself until
// a figure existed could never be used to make one.
fireEvent.click(screen.getByRole('button', { name: '+' }))
expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
// The whole row is a button too. A click that reached it would select the stop and
// move the map out from under the panel that just opened.
expect(onSelectStop).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '80' }))
expect(onSetStopFill).toHaveBeenCalledWith(20, 80)
expect(screen.queryByRole('dialog')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-047: the badge opens from the keyboard and swallows the key', () => {
wrap(<RoadtripSidebar routes={routes({ days: [refuelling()] })} onSetStopFill={vi.fn()} />)
const badge = screen.getByRole('button', { name: '+' })
// The same span-with-a-role bargain as the header, one row further in: unanswered,
// the only way to the figure is a mouse.
fireEvent.keyDown(badge, { key: 'ArrowDown' })
expect(screen.queryByRole('dialog')).toBeNull()
expect(fireEvent.keyDown(badge, { key: 'Enter' })).toBe(false)
expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-048: only a stop that fills up carries the badge, and its own figure reads as one', () => {
wrap(<RoadtripSidebar routes={routes({ days: [refuelling(100)] })} onSetStopFill={vi.fn()} />)
// An explicit 100 is a decision, not an absent figure: on a trip whose default is 80
// it says this one goes right up, and folding it into the invitation would leave the
// traveller reading 80 on a stop that fills to 100.
expect(screen.getAllByRole('button', { name: '100 %' })).toHaveLength(1)
// Hamburg has none at all. A place the trip is for fills no tank, so a fill figure
// there is a control for a decision nobody makes.
expect(screen.queryByText('+')).toBeNull()
})
})
})
describe('RoadtripModeSwitch', () => {
File diff suppressed because it is too large Load Diff
@@ -140,4 +140,21 @@ describe('RoadtripStayModal', () => {
expect(onSave).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
it('FE-STAYMODAL-013: a stay running past midnight says which day it leaves on', () => {
// The clock wraps modulo 24 h, so without the carry this reads "01:00" for a
// departure the chain places on the next day — the rail says +1 beside it and
// the dialog used to say nothing.
open({ minutes: 180, arrival: '22:00' })
expect(screen.getByText('01:00')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('FE-STAYMODAL-014: a stay inside one day carries no day marker', () => {
open({ minutes: 60, arrival: '12:00' })
expect(screen.getByText('13:00')).toBeInTheDocument()
expect(screen.queryByText('+1')).not.toBeInTheDocument()
})
})
@@ -81,6 +81,11 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
return {
arrive: formatClockTime(formatClock(arrival), is12h),
leave: formatClockTime(formatClock(arrival + minutes), is12h),
// `formatClock` wraps modulo 24 h, so a stay that runs past midnight reads as a
// small number again. The rail carries the day it belongs to along with the time;
// without the same carry here the dialog would quietly say "leave 01:00" for a
// stop the chain places on the next day.
leaveCarry: Math.floor((arrival + minutes) / (24 * 60)) - Math.floor(arrival / (24 * 60)),
}
}, [stop?.arrival, minutes, is12h])
@@ -164,7 +169,15 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
<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 dir="ltr" className="text-body font-semibold tabular-nums text-content">
{times.leave}
{times.leaveCarry > 0 ? (
<span className="ms-0.5">
{`+${times.leaveCarry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
) : null}
</span>
</span>
</div>
) : null}
@@ -0,0 +1,112 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import StopFillPicker from './StopFillPicker'
/**
* FE-STOPFILL-001..010 — how full one stop fills, as opposed to how full they all do.
*
* The figure is two-sided: a stop either has its own or borrows the traveller's, and the
* panel has to open showing which. What is worth pinning is the seam between them —
* that an inherited figure reads as chosen, that picking it again hands the stop back to
* the setting rather than writing the same number, and that the kilometre line under the
* pills is absent rather than invented when no range is set.
*/
let anchor: HTMLElement
beforeEach(() => {
anchor = document.createElement('button')
document.body.appendChild(anchor)
useSettingsStore.setState({
settings: { roadtrip_fill_percent: 80, roadtrip_range_km: 500, distance_unit: 'metric' } as never,
})
})
afterEach(() => { anchor.remove() })
function open(over: { current?: number | null; onPick?: (p: number | null) => void } = {}) {
const onPick = over.onPick ?? vi.fn()
const onClose = vi.fn()
render(
<StopFillPicker
anchor={anchor}
current={over.current ?? null}
onPick={onPick}
onClose={onClose}
/>,
)
return { onPick, onClose }
}
const pill = (percent: number) => screen.getByRole('button', { name: String(percent) })
describe('StopFillPicker', () => {
it('FE-STOPFILL-001: without an anchor there is nothing to hang under', () => {
render(<StopFillPicker anchor={null} current={null} onPick={vi.fn()} onClose={vi.fn()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-STOPFILL-002: the traveller own figure is the one shown as chosen', () => {
// Nothing said about this stop, so it fills the way every other one does. Opening
// with nothing pressed would suggest the stop has no answer, when it has one.
open({ current: null })
expect(pill(80)).toHaveAttribute('aria-pressed', 'true')
expect(pill(100)).toHaveAttribute('aria-pressed', 'false')
})
it('FE-STOPFILL-003: the stop own figure wins over the traveller own', () => {
open({ current: 60 })
expect(pill(60)).toHaveAttribute('aria-pressed', 'true')
expect(pill(80)).toHaveAttribute('aria-pressed', 'false')
})
it('FE-STOPFILL-004: picking a percentage reports it', () => {
const { onPick } = open({ current: null })
fireEvent.click(pill(60))
expect(onPick).toHaveBeenCalledWith(60)
})
it('FE-STOPFILL-005: picking the figure the stop already has hands it back to the setting', () => {
// The same gesture the kind picker uses to un-pick a kind. Writing 60 again would
// leave the stop pinned to a number it only ever had by inheritance.
const { onPick } = open({ current: 60 })
fireEvent.click(pill(60))
expect(onPick).toHaveBeenCalledWith(null)
})
it('FE-STOPFILL-006: an inherited figure can be pinned by picking it', () => {
// Pressed because it is what the stop uses, but not its own — so clicking it means
// "make this one mine", not "take it off".
const { onPick } = open({ current: null })
fireEvent.click(pill(80))
expect(onPick).toHaveBeenCalledWith(80)
})
it('FE-STOPFILL-007: what the choice buys is stated, and only when it is known', () => {
open({ current: 60 })
// 60 % of a 500 km range.
expect(screen.getByText(/300 km/)).toBeInTheDocument()
})
it('FE-STOPFILL-008: with no range set the kilometres are absent rather than invented', () => {
useSettingsStore.setState({
settings: { roadtrip_fill_percent: 80, distance_unit: 'metric' } as never,
})
open({ current: 60 })
expect(screen.queryByText(/km/)).not.toBeInTheDocument()
})
it('FE-STOPFILL-009: the way back to the default appears only once there is one to undo', () => {
// On a stop with no figure of its own it would be a button that says "leave
// everything as it is".
open({ current: null })
expect(screen.queryByText('Use my default')).not.toBeInTheDocument()
})
it('FE-STOPFILL-010: a stop with its own figure can be handed back to the setting', () => {
const { onPick } = open({ current: 60 })
fireEvent.click(screen.getByText('Use my default'))
expect(onPick).toHaveBeenCalledWith(null)
})
})
@@ -0,0 +1,111 @@
import React from 'react'
import { RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import AnchoredPopover from './AnchoredPopover'
import { useVehicleRange } from './useVehicleRange'
import { FS } from './typeScale'
import type { DistanceUnit } from '../../types'
/**
* How full THIS stop fills up.
*
* A property of the stop rather than of the traveller, which is the whole reason it is
* here and not only in the settings dialog: a motorway rapid charger is worth about 80 %
* because the last fifth costs as long again, while the one at the hotel is worth all of
* it because the car stands there all night. One figure for the whole trip cannot say
* both, and the difference between them is a leg.
*
* Six pills rather than a slider or a field. A tank is not filled to 63 %, and the number
* being chosen is a decision — "top up" or "fill right up" — not a measurement, so a
* control that offers the six answers people actually give is one tap where a slider is a
* drag and a field is a keyboard.
*
* Under them, what the choice is worth in kilometres, because 80 % is an abstraction and
* "about 290 km from here" is the thing the traveller is actually deciding.
*/
/** The answers people actually give. Below half a tank nobody stops on purpose. */
const PRESETS = [50, 60, 70, 80, 90, 100] as const
export default function StopFillPicker({ anchor, current, onPick, onClose }: {
/** The element the popover hangs under: the stop's own fill badge. */
anchor: HTMLElement | null
/** What this stop says, or null when it follows the traveller's own setting. */
current: number | null
/** A percentage for this stop, or null to hand it back to the setting. */
onPick: (percent: number | null) => void
onClose: () => void
}): React.ReactElement | null {
const { t } = useTranslation()
const unit: DistanceUnit = useSettingsStore(s => s.settings.distance_unit) === 'imperial' ? 'imperial' : 'metric'
const fallback = useSettingsStore(s => s.settings.roadtrip_fill_percent)
const { rangeKm } = useVehicleRange()
// Zero, absent and 100 all mean the same thing in the settings, so they read as full
// here too rather than as three different defaults.
const settingPercent = fallback && fallback > 0 && fallback < 100 ? fallback : 100
const shown = current ?? settingPercent
return (
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.fillTitle')} onClose={onClose}>
<div className="flex flex-col gap-2 p-0.5">
<p
className="px-1 font-geist font-semibold uppercase tracking-[0.12em] text-content-faint"
style={{ fontSize: FS.label }}
>
{t('roadtrip.stop.fillTitle')}
</p>
<div className="flex gap-1">
{PRESETS.map(percent => {
// Pressed against what the stop will actually use, inherited or not, so the
// panel opens showing where the stop stands rather than showing nothing.
const on = shown === percent
return (
<button
key={percent}
type="button"
aria-pressed={on}
// Picking what it already is hands it back to the setting, the same way
// the kind picker un-picks a kind it already has.
onClick={() => onPick(current === percent ? null : percent)}
className={`h-9 w-9 rounded-xl border text-caption font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
on
? 'border-accent bg-accent text-accent-text'
: 'border-edge bg-surface-card text-content-secondary hover:bg-surface-hover'
}`}
>
{percent}
</button>
)
})}
</div>
{/* What the choice buys. Absent rather than guessed when no range is set: a
kilometre figure worked out from nothing would be the most convincing wrong
number on the screen. */}
<p className="px-1 text-caption text-content-muted">
{rangeKm
? t('roadtrip.stop.fillGives', { distance: formatDistance((rangeKm * shown) / 100, unit) })
: t('roadtrip.stop.fillNoRange')}
</p>
{/* Only once there is something to undo. Without an override it would be a button
that says "leave everything as it is". */}
{current !== null ? (
<button
type="button"
onClick={() => onPick(null)}
className="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"
>
<RotateCcw size={14} className="shrink-0 text-content-faint" aria-hidden />
<span className="flex-1 text-start">{t('roadtrip.stop.fillDefault')}</span>
<span className="tabular-nums text-content-faint">{`${settingPercent} %`}</span>
</button>
) : null}
</div>
</AnchoredPopover>
)
}
@@ -1,8 +1,8 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import React from 'react'
import { MapPin } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import AnchoredPopover from './AnchoredPopover'
import { STOP_KINDS } from './stopKinds'
import type { RoadtripStopType } from '@trek/shared'
@@ -33,61 +33,9 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
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' }}
>
return (
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.kind')} onClose={onClose}>
<div className="flex gap-1">
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
const on = current === key
@@ -127,7 +75,6 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
{t('roadtrip.stop.backToDestination')}
</button>
) : null}
</div>,
document.body,
</AnchoredPopover>
)
}
@@ -12,7 +12,7 @@ import type { RouteAlternative } from '../Map/RouteCalculator'
* reason this is not simply the midpoint.
*/
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry' }
/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */
function line(n: number, lat: number): [number, number][] {
@@ -83,7 +83,7 @@ function mostDistinctPoint(
*/
export function buildAlternativeOverlays(
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string },
): AlternativeOverlay[] {
if (!routes?.length) return []
// A single answer is not a choice; drawing it would just double the route already there.
@@ -114,7 +114,9 @@ export function buildAlternativeOverlays(
? labels.noMotorway
: route.avoids === 'toll'
? labels.noToll
: index === quickest ? labels.fastest : '',
: route.avoids === 'ferry'
? labels.noFerry
: index === quickest ? labels.fastest : '',
duration: route.duration,
distance: route.distance,
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
@@ -1,9 +1,12 @@
import { describe, it, expect } from 'vitest'
import {
boxAround,
corridorTiles,
distanceToSegmentKm,
haversineKm,
pointAtMeters,
projectOntoRoute,
sliceAtMeters,
simplifyLine,
type LatLng,
} from './corridor'
@@ -155,3 +158,106 @@ describe('simplifyLine', () => {
expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN])
})
})
describe('pointAtMeters', () => {
// A straight run due east along one parallel, so the arithmetic is checkable by hand:
// at 52° north a degree of longitude is about 68.5 km.
const WEST: LatLng = { lat: 52, lng: 13 }
const EAST: LatLng = { lat: 52, lng: 14 }
const LEG = haversineKm(WEST, EAST) * 1000
it('lands halfway along a single segment', () => {
const mid = pointAtMeters([WEST, EAST], LEG / 2)!
expect(mid.lat).toBeCloseTo(52, 5)
expect(mid.lng).toBeCloseTo(13.5, 3)
})
it('walks past the first segment into the second', () => {
const line = [WEST, EAST, { lat: 52, lng: 15 }]
const at = pointAtMeters(line, LEG * 1.5)!
// One and a half segments in, so halfway through the second.
expect(at.lng).toBeGreaterThan(14)
expect(at.lng).toBeLessThan(15)
})
it('returns the last point past the end rather than nothing', () => {
// A tank that runs out after the day's final stop still ran out somewhere; the
// caller decides what to do about that, so this must not answer null.
expect(pointAtMeters([WEST, EAST], LEG * 10)).toEqual(EAST)
})
it('answers the first point for zero, and nothing for an empty line', () => {
expect(pointAtMeters([WEST, EAST], 0)).toEqual(WEST)
expect(pointAtMeters([WEST, EAST], -5)).toEqual(WEST)
expect(pointAtMeters([], 100)).toBeNull()
expect(pointAtMeters([WEST], 100)).toEqual(WEST)
})
it('survives a repeated vertex without dividing by zero', () => {
// A route line concatenated from several runs does contain these.
const at = pointAtMeters([WEST, WEST, EAST], LEG / 2)!
expect(at.lng).toBeCloseTo(13.5, 3)
})
})
describe('boxAround', () => {
it('reaches the asked radius on all four sides', () => {
const box = boxAround(BERLIN, 10)
// North and south are the easy ones: a degree of latitude is constant.
expect(haversineKm(BERLIN, { lat: box.north, lng: BERLIN.lng })).toBeCloseTo(10, 0)
expect(haversineKm(BERLIN, { lat: BERLIN.lat, lng: box.east })).toBeCloseTo(10, 0)
})
it('widens the longitude span the further north it sits', () => {
// The same radius is more degrees of longitude in Tromsø than in Berlin.
const berlin = boxAround(BERLIN, 10)
const tromso = boxAround({ lat: 69.65, lng: 18.96 }, 10)
expect(tromso.east - tromso.west).toBeGreaterThan(berlin.east - berlin.west)
})
})
describe('sliceAtMeters', () => {
// A degree of latitude is about 111 km, so this line is roughly 111 km long and every
// figure below can be read off it without a calculator.
const line: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 53, lng: 13 }]
it('cuts a stretch out of the middle and interpolates both ends', () => {
const out = sliceAtMeters(line, 30_000, 60_000)
expect(out).toHaveLength(2)
expect(haversineKm(out[0], out[1]) * 1000).toBeGreaterThan(29_000)
expect(haversineKm(out[0], out[1]) * 1000).toBeLessThan(31_000)
})
it('two adjacent slices meet exactly, so the parts rebuild the whole', () => {
// The reason this exists: a day's line is its legs end to end, and a night drive
// takes its own leg to the next card. A gap at the join would draw as a break in
// the road.
const first = sliceAtMeters(line, 0, 40_000)
const second = sliceAtMeters(line, 40_000, 80_000)
const end = first[first.length - 1]
expect(haversineKm(end, second[0])).toBeLessThan(0.001)
})
it('clamps to the line rather than running past its end', () => {
const out = sliceAtMeters(line, 0, 999_000_000)
expect(out[out.length - 1]).toEqual(line[line.length - 1])
})
it('is empty for a zero-length or backwards range', () => {
expect(sliceAtMeters(line, 50_000, 50_000)).toEqual([])
expect(sliceAtMeters(line, 60_000, 30_000)).toEqual([])
})
it('is empty for a line that is not one', () => {
expect(sliceAtMeters([], 0, 100)).toEqual([])
expect(sliceAtMeters([{ lat: 52, lng: 13 }], 0, 100)).toEqual([])
})
it('survives a repeated vertex without producing NaN', () => {
// A concatenated route line really does contain these, and dividing by a zero-length
// segment is how a route full of NaN reaches the map.
const doubled: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 52, lng: 13 }, { lat: 53, lng: 13 }]
const out = sliceAtMeters(doubled, 10_000, 20_000)
expect(out.every(p => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true)
})
})
+108
View File
@@ -232,3 +232,111 @@ export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
const right = simplifyLine(line.slice(index), toleranceKm)
return [...left.slice(0, -1), ...right]
}
/**
* The point that lies `metres` along a line, measured the way a car drives it.
*
* Every other helper in this file goes the other way: given a point, where on the line
* is it. This is the inverse, and it exists because the range budget produces a distance
* ("the tank is empty 340 km in") with no idea where that is on the map.
*
* Walked segment by segment rather than approximated, and interpolated inside the
* segment it lands in, because the answer becomes a marker somebody drives to. Past the
* end it returns the last point rather than null: a tank that runs out after the day's
* final stop still ran out somewhere, and the caller decides what to do about that.
*/
export function pointAtMeters(line: LatLng[], metres: number): LatLng | null {
if (!line.length) return null
if (line.length === 1 || metres <= 0) return line[0]
let covered = 0
for (let i = 1; i < line.length; i++) {
const step = haversineKm(line[i - 1], line[i]) * 1000
if (covered + step >= metres) {
// How far into this segment, as a fraction. A zero-length segment (two identical
// vertices, which a concatenated route line does produce) would divide by zero,
// and its start point is the right answer anyway.
const t = step > 0 ? (metres - covered) / step : 0
return {
lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t,
lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t,
}
}
covered += step
}
return line[line.length - 1]
}
/**
* How long a line is, in metres, measured the way `sliceAtMeters` walks it.
*
* Not the same figure the router reports for the same road: that comes off its own graph
* while this is great-circle hops between the vertices it sent back. Anything cutting a
* line at a distance has to measure it with this, or the last cut falls short of the end
* by the difference.
*/
export function lineMetres(line: LatLng[]): number {
let total = 0
for (let i = 1; i < line.length; i++) total += haversineKm(line[i - 1], line[i]) * 1000
return total
}
/**
* The stretch of a line between two distances along it, ends included.
*
* The router answers a whole run with one polyline and a leg list beside it, so the road
* belonging to any single leg has to be cut out afterwards. Which matters because a road
* trip draws its stops by the day they are REACHED: a drive across midnight hands its
* stops to the next card, and the road under them has to go with them — otherwise the
* day they set off from keeps a line nobody drives on it, and the corridor search along
* "day 2" looks at road that was covered yesterday.
*
* Both ends are interpolated inside the segment they fall in rather than snapped to the
* nearest vertex, so two adjacent slices meet exactly and concatenating them gives the
* original line back.
*/
export function sliceAtMeters(line: LatLng[], fromMetres: number, toMetres: number): LatLng[] {
if (line.length < 2 || toMetres <= fromMetres) return []
const from = Math.max(0, fromMetres)
const out: LatLng[] = []
let covered = 0
for (let i = 1; i < line.length; i++) {
const step = haversineKm(line[i - 1], line[i]) * 1000
const segStart = covered
const segEnd = covered + step
covered = segEnd
if (segEnd < from) continue
if (segStart > toMetres) break
// A zero-length segment divides by zero; both its ends are the same point, so the
// fraction does not matter and zero is the one that never produces NaN.
const at = (m: number): LatLng => {
const t = step > 0 ? (m - segStart) / step : 0
return {
lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t,
lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t,
}
}
if (!out.length) out.push(segStart >= from ? line[i - 1] : at(from))
out.push(segEnd <= toMetres ? line[i] : at(toMetres))
}
return out.length > 1 ? out : []
}
/**
* A box around a point, sized so its inscribed circle reaches `radiusKm`.
*
* The server turns a box into centre plus half the diagonal, so a box of ±r asks for
* r·√2 of reach and finds things in the corners that are further away than they look.
* Sized from the radius rather than guessed, and the caller still has to read the
* answer's own `clamped` flag: above 20 km the index narrows the search silently.
*/
export function boxAround(point: LatLng, radiusKm: number): Bbox {
const dLat = radiusKm / KM_PER_DEG_LAT
const dLng = radiusKm / (KM_PER_DEG_LAT * Math.max(0.01, Math.cos(toRad(point.lat))))
return {
south: point.lat - dLat,
west: point.lng - dLng,
north: point.lat + dLat,
east: point.lng + dLng,
}
}
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { dayColor } from './dayColors'
describe('dayColor', () => {
it('FE-DAYCOLORS-001: day one is the blue the route has always been', () => {
// So a one-day trip looks exactly as it did before colouring by day existed.
expect(dayColor(1).line).toBe('#0a84ff')
})
it('FE-DAYCOLORS-002: consecutive days differ', () => {
const seen = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(n => dayColor(n).line))
expect(seen.size).toBe(8)
})
it('FE-DAYCOLORS-003: the ninth day starts the palette again', () => {
expect(dayColor(9)).toEqual(dayColor(1))
expect(dayColor(17)).toEqual(dayColor(1))
})
it('FE-DAYCOLORS-004: the casing is its own colour, not the core', () => {
// A single fixed casing went muddy under the warm colours, which is why each entry
// carries a darkened hue of its own.
for (const n of [1, 2, 3, 4, 5, 6, 7, 8]) {
expect(dayColor(n).casing).not.toBe(dayColor(n).line)
}
})
it('FE-DAYCOLORS-005: a missing or nonsensical day still draws something', () => {
// A day number is read off data, and a route with no colour at all draws nothing.
expect(dayColor(0)).toEqual(dayColor(1))
expect(dayColor(-3)).toEqual(dayColor(1))
expect(dayColor(Number.NaN)).toEqual(dayColor(1))
})
})
@@ -0,0 +1,43 @@
/**
* A colour per day, for a road trip drawn end to end.
*
* The map draws one blue line for a whole trip, which is right while the days are read as
* separate cards. Once the days are joined into one continuous drive there is nothing in
* the line to say where one ends, so the colour has to do it.
*
* Fixed hex rather than theme tokens, and deliberately so: these sit on map tiles, not on
* the app's own surfaces, and a token that follows the user's accent would put the route
* in the same colour as the terrain on some schemes. The same exception the rest of the
* map's paint takes.
*/
/**
* Eight, then it repeats.
*
* Picked for separation at a 5px stroke over a pale basemap rather than for a gradient: a
* ramp reads as "later" and days are not a quantity. Blue stays first because it is the
* colour the route has always been, so a one-day trip looks unchanged.
*
* The second value is the casing under the core — the same hue darkened, which is what
* keeps the line readable over water and forest. A single fixed casing would have gone
* muddy under the warm colours.
*/
const DAY_COLORS: readonly [string, string][] = [
['#0a84ff', '#0a5cc2'], // theme-lint-disable — map paint, see file comment
['#ff9f0a', '#c2740a'], // theme-lint-disable
['#30d158', '#1f9c40'], // theme-lint-disable
['#bf5af2', '#8e3fb8'], // theme-lint-disable
['#ff375f', '#c22546'], // theme-lint-disable
['#64d2ff', '#3d9dc2'], // theme-lint-disable
['#ffd60a', '#c2a30a'], // theme-lint-disable
['#ac8e68', '#7d654a'], // theme-lint-disable
]
/** The core and casing a given day of the trip is drawn in. Day numbers start at 1. */
export function dayColor(dayNumber: number): { line: string; casing: string } {
// A day number that is missing or nonsensical still has to draw something, and the
// first colour is the one the route had before any of this existed.
const at = Number.isFinite(dayNumber) && dayNumber > 0 ? (dayNumber - 1) % DAY_COLORS.length : 0
const [line, casing] = DAY_COLORS[at]
return { line, casing }
}
@@ -0,0 +1,258 @@
import { describe, it, expect } from 'vitest'
import { spillChains } from './nightSpill'
import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
import type { RouteSegment } from '../../types'
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
return {
ownerDayId: 1,
ownerIndex: 0,
placeId: over.assignmentId * 10,
lat: 53.5,
lng: 9.9,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
}
}
/** A day of the plan, with every stop's stored position filled in for it. */
function day(dayId: number, dayNumber: number, stops: RoadtripStop[]): PlanDay {
return {
dayId,
dayNumber,
date: null,
title: null,
stops: stops.map((s, i) => ({ ...s, ownerDayId: dayId, ownerIndex: i })),
}
}
const seg = (minutes: number, km = 100): RouteSegment =>
({ distance: km * 1000, duration: minutes * 60, distanceText: `${km} km`, durationText: `${minutes} min`, mode: 'driving' }) as RouteSegment
/** Every leg the same length, which is all most of these cases need. */
const everyLeg = (minutes: number, km = 100) => () => ({ seg: seg(minutes, km), line: [[0, 0], [1, 1]] as [number, number][] })
/** No leg routed at all, which is what an unrouted or offline trip looks like. */
const noLegs = () => undefined
describe('spillChains', () => {
it('FE-NIGHTSPILL-001: leaves an ordinary day exactly where it is', () => {
const plan = [day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '09:00', dwellMinutes: 60 }),
stop({ assignmentId: 2, name: 'Berlin' }),
])]
const chains = spillChains(plan, [], everyLeg(180))
expect(chains).toHaveLength(1)
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Berlin'])
expect(chains[0].spills).toEqual([])
})
it('FE-NIGHTSPILL-002: hands the stops reached after midnight to the next day', () => {
// 21:00 + 1 h 30 stay = 22:30, then a three hour drive arrives at 01:30 tomorrow.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
]),
day(2, 2, [
stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }),
stop({ assignmentId: 4, name: 'Leipzig' }),
]),
]
const chains = spillChains(plan, [], everyLeg(180))
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg'])
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Wittenberg', 'Leipzig'])
})
it('FE-NIGHTSPILL-003: the spill carries the night drive and the time it set off', () => {
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
]),
day(2, 2, [
stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }),
stop({ assignmentId: 4, name: 'Leipzig' }),
]),
]
const chains = spillChains(plan, [], everyLeg(180, 240))
expect(chains[1].spills).toHaveLength(1)
const spill = chains[1].spills[0]
expect(spill).toMatchObject({ at: 0, count: 1, fromDayNumber: 1, departure: '22:30' })
// The leg is the drive that crossed midnight, not the one after it: without it the
// card would gain a stop and none of the distance that reaches it.
expect(spill.leg?.distance).toBe(240000)
expect(spill.line.length).toBeGreaterThan(1)
})
it('FE-NIGHTSPILL-004: a stop that moved keeps the day the server stores it on', () => {
const plan = [
day(7, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
]),
day(8, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
]
const chains = spillChains(plan, [], everyLeg(180))
// Drawn under day 2, stored on day 1 — which is what every callback still names.
const moved = chains[1].stops.find(s => s.name === 'Neuruppin')
expect(moved?.ownerDayId).toBe(7)
expect(moved?.ownerIndex).toBe(1)
})
it('FE-NIGHTSPILL-005: the last day of a trip keeps its own crossing, having nowhere to send it', () => {
const plan = [day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
])]
const chains = spillChains(plan, [], everyLeg(180))
expect(chains).toHaveLength(1)
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin'])
expect(chains[0].spills).toEqual([])
// And it still says so: the arrival is a day past the one the card is under.
expect(chains[0].schedule.entries[1].dayOffset).toBe(1)
})
it('FE-NIGHTSPILL-006: a pinned time resolved to yesterday does not tear the day apart', () => {
// The case that broke it: 07:00 then 23:00. `computeSchedule` resolves the second pin
// to the occurrence nearest the drive reaching it, so nine hours late reads as nearer
// than fifteen hours of waiting and 23:00 lands on the PREVIOUS day. The whole chain
// is then lifted to stay positive, which leaves the FIRST stop wearing a day offset
// and the one after it none. Placing stops on those offsets moved the early stop to
// tomorrow and left the late one behind, visiting them out of order.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Cobbelsdorf', time: '07:00', dwellMinutes: 45 }),
stop({ assignmentId: 2, name: 'Wittenberg', time: '23:00', dwellMinutes: 180 }),
stop({ assignmentId: 3, name: 'Leipzig' }),
]),
day(2, 2, [stop({ assignmentId: 4, name: 'Dresden', time: '12:00' })]),
]
const chains = spillChains(plan, [], everyLeg(50))
// Cobbelsdorf and Wittenberg both stay: nothing between them crosses midnight.
expect(chains[0].stops.map(s => s.name)).toEqual(['Cobbelsdorf', 'Wittenberg'])
// Leipzig is reached at 02:50, after the clock went back past 23:00, so it moves.
expect(chains[1].stops.map(s => s.name)).toEqual(['Leipzig', 'Dresden'])
})
it('FE-NIGHTSPILL-007: two nights land on two different days', () => {
// 20:00, then two twelve-hour drives: 08:00 the next day, 20:00 that evening, and
// 08:00 the day after — two backwards jumps of the clock, two days on.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'A', time: '20:00' }),
stop({ assignmentId: 2, name: 'B' }),
stop({ assignmentId: 3, name: 'C' }),
stop({ assignmentId: 4, name: 'D' }),
]),
day(2, 2, []),
day(3, 3, []),
]
const chains = spillChains(plan, [], everyLeg(12 * 60))
expect(chains[0].stops.map(s => s.name)).toEqual(['A'])
expect(chains[1].stops.map(s => s.name)).toEqual(['B', 'C'])
expect(chains[2].stops.map(s => s.name)).toEqual(['D'])
})
it('FE-NIGHTSPILL-008: a day whose legs never routed moves nothing', () => {
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00' }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
]),
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
]
const chains = spillChains(plan, [], noLegs)
// No drive, no arrival, no crossing — and a guess would be worse than nothing.
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin'])
expect(chains[1].spills).toEqual([])
})
it('FE-NIGHTSPILL-009: a day that carries no drive of its own still receives one', () => {
const plan = [day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
])]
const quiet: QuietDay[] = [{ dayId: 2, dayNumber: 2, date: null, title: null, stops: [] }]
const chains = spillChains(plan, quiet, everyLeg(180))
expect(chains).toHaveLength(2)
expect(chains[1].dayId).toBe(2)
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin'])
})
it('FE-NIGHTSPILL-010: the crossing is not marked twice on a stop that moved', () => {
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
]),
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
]
const chains = spillChains(plan, [], everyLeg(180))
// The block around it is what says the night happened; a band as well would say it
// twice. And it is not "the next day" once it is drawn under the next day's date.
expect(chains[1].schedule.warnings.some(w => w.code === 'overnight')).toBe(false)
expect(chains[1].schedule.entries[0].dayOffset).toBe(0)
})
it('FE-NIGHTSPILL-011: a finding about a stop travels with it, renumbered', () => {
// Wittenberg is pinned two hours before the drive can get there, which is a "late"
// finding against index 1 of day 1. It moves to day 2 as index 0.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin', time: '00:30' }),
]),
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
]
const chains = spillChains(plan, [], everyLeg(180))
const late = chains[1].schedule.warnings.filter(w => w.code === 'late')
expect(late).toHaveLength(1)
expect(late[0].index).toBe(0)
})
it('FE-NIGHTSPILL-012: two stops crossing together are one block, not two', () => {
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
stop({ assignmentId: 3, name: 'Potsdam' }),
]),
day(2, 2, [stop({ assignmentId: 4, name: 'Wittenberg', time: '10:00' })]),
]
const chains = spillChains(plan, [], everyLeg(180))
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Potsdam', 'Wittenberg'])
expect(chains[1].spills).toHaveLength(1)
expect(chains[1].spills[0]).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 })
})
})
@@ -0,0 +1,212 @@
import { computeSchedule, parseClock, type Schedule } from './roadtripModel'
import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
import type { RouteSegment } from '../../types'
/**
* Which date each stop of a road trip is drawn on.
*
* A day plan stores a stop against a day, and that is the right model for planning: the
* traveller put it there. But a road trip is read as a drive, and a drive that leaves at
* 21:00 and arrives at 01:23 arrives TOMORROW. The rail used to say so with a band
* between the two stops and then carry on listing the arrival under yesterday's date —
* the one thing the band had just said was wrong.
*
* So a road trip lays its stops out by the day they are actually reached, and the drive
* across midnight travels with them, which is what makes the kilometres count on the day
* they are driven rather than on the day they were set off on.
*
* **Nothing is written.** The stop still belongs to the day it was planned on, the day
* plan shows it there, and every stop carries `ownerDayId`/`ownerIndex` so a reorder, a
* move, a stay edit or a refuel offer still names the day the server knows it by. Because
* this is worked out again from the arrival times every time they change, shortening the
* stay before a night drive brings the stop back on its own — there is no state to undo,
* and no way for the arrangement and the times to disagree.
*/
/** A stretch of one card's chain that was driven onto it from an earlier day. */
export interface SpillMark {
/** Where it starts in the card's `stops`. */
at: number
/** How many stops it covers. */
count: number
/** The day they are stored on, as the traveller numbers it. */
fromDayNumber: number
/** When the drive set off, read off that day's own schedule. */
departure: string | null
/** The drive across midnight itself, or undefined when that leg never routed. */
leg: RouteSegment | undefined
/**
* The stop the night drive LEAVES from, still on the earlier card.
*
* Needed because that drive is drawn on this card while belonging, leg-wise, to the
* stop before it: anything measured along this card's line that lands on the incoming
* stretch has to be anchored to this stop, not to the first one drawn here. Putting a
* via there and anchoring it to the wrong stop is what made a route double back.
*/
fromStop: RoadtripStop | undefined
/**
* The road it covers, so the card it lands on can draw it and count it.
*
* Without this the night is a hole: the day it set off from ends before it and the day
* it arrives on starts after it, so its kilometres belong to neither and the map draws
* two runs with a gap between them.
*/
line: [number, number][]
}
/** One date's worth of driving, before the legs and warnings are hung off it. */
export interface SpillChain {
dayId: number
dayNumber: number
date: string | null
title: string | null
/** In driving order: anything inherited first, then the day's own. */
stops: RoadtripStop[]
/** Arrivals as each stop's OWN day worked them out, renumbered onto this chain. */
schedule: Schedule
/** The inherited stretches, in the order they appear. Empty on an ordinary day. */
spills: SpillMark[]
}
/** A day as the plan holds it, before anything is routed onto it. */
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
/** What the caller can find out about the road between two stops. */
type LegLookup = (from: RoadtripStop, to: RoadtripStop) => { seg: RouteSegment; line: [number, number][] } | undefined
/** One stop on its way to a card, with the times its own day gave it. */
interface Placed {
stop: RoadtripStop
arrival: string | null
departure: string | null
/** Days past its own day's first stop. Renumbered against the card it lands on. */
dayOffset: number
/** Findings its own day filed against it, to be renumbered with it. */
codes: ('late' | 'overnight' | 'leg' | 'range')[]
lateMinutes: number | null
/** Which day it came from, or null when it is the card's own. */
fromDayNumber: number | null
departedAt: string | null
leg: RouteSegment | undefined
line: [number, number][]
from: RoadtripStop | undefined
}
/**
* Lays a trip out by the date each stop is reached.
*
* Every day of the trip gets a chain, in trip order, including the ones that carry no
* drive of their own — a day whose only content was driven onto it through the night is
* still a day, and the alternative is a date that silently disappears. The caller drops
* the chains that end up with fewer than two stops, exactly as it did before.
*/
export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegLookup): SpillChain[] {
const all: PlanDay[] = [...plan, ...quietDays.map(d => ({
dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops,
}))].sort((a, b) => a.dayNumber - b.dayNumber)
const numbers = new Set(all.map(d => d.dayNumber))
const landing = new Map<number, Placed[]>()
for (const d of all) landing.set(d.dayNumber, [])
for (const d of all) {
// A day's own schedule, walked over its own stops: this is what says where midnight
// falls, and it is the only reading of these times that is anchored to the times the
// traveller actually pinned.
const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]))
const legs = routed.map(l => l?.seg)
const schedule = computeSchedule(
d.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
legs.map(l => l?.duration),
)
/**
* Which day of this chain each stop is reached on, read off the clock itself.
*
* Not `dayOffset`. That is arithmetic on top of a pinned time resolved to the
* occurrence NEAREST the drive reaching it — so a stop pinned at 23:00 after one
* pinned at 07:00 resolves to 23:00 yesterday (nine hours late reads as nearer than
* fifteen hours of waiting), the chain drops below midnight, and the schedule lifts
* the whole day back up to keep it positive. The result is an early stop wearing a
* higher offset than the one after it, and placing stops on those offsets tears the
* drive into pieces that are visited out of order.
*
* A clock that goes backwards is the one signal none of that can fake. Arrivals only
* ever move forward along a drive, so 23:00 followed by 03:12 is one thing and one
* thing only: midnight happened in between. A stop with no arrival yet inherits the
* day of the one before it rather than resetting the count.
*/
let day = 0
let previous: number | null = null
d.stops.forEach((stop, i) => {
const entry = schedule.entries[i]
const clock = parseClock(entry?.arrival)
if (clock !== null) {
if (previous !== null && clock < previous) day += 1
previous = clock
}
const offset = day
const marks = schedule.warnings.filter(w => w.index === i)
// A day the trip does not have is not a day to move onto. Those stops stay where
// they are and keep the crossing marked the way the rail always marked it, because
// the only other answer would be to invent a date nobody planned. The ratchet stays
// where it was too, so the rest of the chain stays with them.
const reachable = offset > 0 && numbers.has(d.dayNumber + offset)
const target = reachable ? d.dayNumber + offset : d.dayNumber
const moved = target !== d.dayNumber
landing.get(target)?.push({
stop,
arrival: entry?.arrival ?? null,
departure: entry?.departure ?? null,
// Renumbered against the card it lands on: 01:23 is not "the next day" once it is
// drawn under the next day's date. A stop that could not move keeps its offset,
// which is what still makes the "+1" and the crossing band read correctly.
dayOffset: moved ? 0 : offset,
// The crossing itself is the spill block's own head wherever the stop moved, so
// it would be said twice.
codes: marks.map(w => w.code).filter(c => !(moved && c === 'overnight')),
lateMinutes: marks.find(w => w.code === 'late')?.minutes ?? null,
fromDayNumber: moved ? d.dayNumber : null,
departedAt: moved ? schedule.entries[i - 1]?.departure ?? null : null,
leg: moved ? legs[i - 1] : undefined,
line: moved ? routed[i - 1]?.line ?? [] : [],
from: moved ? d.stops[i - 1] : undefined,
})
})
}
const out: SpillChain[] = []
for (const d of all) {
const placed = landing.get(d.dayNumber) ?? []
if (!placed.length) continue
// Inherited stretches first, oldest day first, then the card's own stops: that is
// the order they are driven in.
placed.sort((a, b) => (a.fromDayNumber ?? Number.MAX_SAFE_INTEGER) - (b.fromDayNumber ?? Number.MAX_SAFE_INTEGER))
const spills: SpillMark[] = []
for (let i = 0; i < placed.length; i++) {
const p = placed[i]
if (p.fromDayNumber === null) continue
const last = spills[spills.length - 1]
if (last && last.fromDayNumber === p.fromDayNumber && last.at + last.count === i) last.count += 1
else spills.push({ at: i, count: 1, fromDayNumber: p.fromDayNumber, departure: p.departedAt, leg: p.leg, line: p.line, fromStop: p.from })
}
out.push({
dayId: d.dayId,
dayNumber: d.dayNumber,
date: d.date,
title: d.title,
stops: placed.map(p => p.stop),
schedule: {
entries: placed.map(p => ({
arrival: p.arrival,
departure: p.departure,
anchored: p.stop.time !== null && p.stop.time !== undefined,
dayOffset: p.dayOffset,
})),
warnings: placed.flatMap((p, i) => p.codes.map(code => (
code === 'late' ? { index: i, code, minutes: p.lateMinutes ?? 0 } : { index: i, code }
))),
},
spills,
})
}
return out
}
@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest'
import { reachableRefuels, outcomeOf } from './refuelSuggestion'
import type { LatLng } from './corridor'
import type { Poi } from '../Map/poiCategories'
// A straight run due east at 52° north, where a degree of longitude is about 68.5 km.
const LINE: LatLng[] = [
{ lat: 52, lng: 13 },
{ lat: 52, lng: 14 },
{ lat: 52, lng: 15 },
]
const poi = (name: string, lat: number, lng: number): Poi =>
({ osm_id: `node:${name}`, name, lat, lng } as Poi)
describe('reachableRefuels', () => {
it('FE-ROADTRIP-REFUEL-001: offers only what lies before the tank runs out', () => {
// Dry at 100 km in. A pump at 50 km is reachable; one at 130 km is on the far side
// of an empty tank, and offering it is offering a walk.
const early = poi('early', 52, 13.73) // ~50 km
const late = poi('late', 52, 14.9) // ~130 km
const out = reachableRefuels([early, late], LINE, 100)
expect(out.map(p => p.name)).toEqual(['early'])
})
it('FE-ROADTRIP-REFUEL-002: the detour counts twice, because it is driven out and back', () => {
// Both sit at the same point along the road, 60 km in, with 100 km of range. One is
// on the road; the other is 20 km off it, so 40 km of driving, which no longer fits
// in the 40 km that are left once the reserve is kept back.
const onRoad = poi('on-road', 52, 13.876)
const offRoad = poi('off-road', 52.18, 13.876) // ~20 km north
const out = reachableRefuels([onRoad, offRoad], LINE, 100)
expect(out.map(p => p.name)).toEqual(['on-road'])
})
it('FE-ROADTRIP-REFUEL-003: a reserve is kept back, so nobody is sent to the last drop', () => {
// 8 km before the dry point is inside the ten kilometre reserve.
const onTheLimit = poi('on-the-limit', 52, 14.34) // ~93 km
expect(reachableRefuels([onTheLimit], LINE, 100)).toEqual([])
})
it('FE-ROADTRIP-REFUEL-004: the smallest detour comes first, however late the others sit', () => {
// Measured on a real day: the station that got furthest was 9.2 km off the road for
// one kilometre more of range. Everything in this list is already reachable, so what
// separates them is what the stop costs.
const nearRoad = poi('near-road', 52, 13.15) // on the line, early
const bigDetour = poi('big-detour', 52.05, 13.9) // ~5.5 km off, later
const out = reachableRefuels([bigDetour, nearRoad], LINE, 100)
expect(out.map(p => p.name)).toEqual(['near-road', 'big-detour'])
})
it('FE-ROADTRIP-REFUEL-009: with the same detour, the later one wins', () => {
// Filling up early wastes the tank that was already paid for.
const early = poi('early', 52, 13.15)
const later = poi('later', 52, 14.16)
const out = reachableRefuels([early, later], LINE, 100)
expect(out.map(p => p.name)).toEqual(['later', 'early'])
})
it('FE-ROADTRIP-REFUEL-005: a pump already on the plan is not suggested next to itself', () => {
// Matched on position, not on id: the same station added by hand or from another
// source carries a different osm_id.
const pump = poi('pump', 52, 13.73)
const out = reachableRefuels([pump], LINE, 100, { existing: [{ lat: 52.0005, lng: 13.7302 }] })
expect(out).toEqual([])
})
it('FE-ROADTRIP-REFUEL-006: something nowhere near the road is not on the way', () => {
const wayOff = poi('way-off', 49, 13.5)
expect(reachableRefuels([wayOff], LINE, 100)).toEqual([])
})
})
describe('outcomeOf', () => {
it('FE-ROADTRIP-REFUEL-007: no answer is not the same as no filling station', () => {
// The distinction the whole band hangs on. Saying "none on this stretch" after a
// failed request states a fact that was never checked.
expect(outcomeOf([], null)).toBe('failed')
expect(outcomeOf([], { truncated: true })).toBe('incomplete')
expect(outcomeOf([], {})).toBe('none')
// `clamped` is not the same thing: it means the server searched a smaller circle
// than the box described, and the caller sizes the box so that never happens.
// Treating it as "could not check" turned every empty answer into a shrug.
expect(outcomeOf([], { clamped: true })).toBe('none')
})
it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => {
const found = [{ name: 'x' }] as never
expect(outcomeOf(found, { truncated: true })).toBe('found')
})
})
describe('reachableRefuels — one entry per station', () => {
const at = (name: string, lat: number, lng: number): Poi =>
({ osm_id: `node:${name}-${lat}-${lng}`, name, lat, lng } as Poi)
it('FE-ROADTRIP-REFUEL-010: a charging site mapped as one node per socket is offered once', () => {
// Measured on a real day: the top three offers were the same "autostrom plus GmbH"
// three times over, because OSM maps every socket as its own node.
// The real shape of it: four nodes in two pairs, 6 m and 7 m apart, with 450 m
// between the pairs.
const sockets = [
at('autostrom plus GmbH', 52, 13.3),
at('autostrom plus GmbH', 52.00005, 13.30005),
at('autostrom plus GmbH', 52.004, 13.3005),
at('autostrom plus GmbH', 52.00405, 13.30045),
]
const out = reachableRefuels(sockets, LINE, 100)
expect(out).toHaveLength(1)
})
it('FE-ROADTRIP-REFUEL-011: the same chain in two towns stays two entries', () => {
// Name alone is not identity: a chain has a branch everywhere.
// Measured: genuinely different branches were 18 to 36 km apart.
const out = reachableRefuels([at('Aral', 52, 13.2), at('Aral', 52, 13.6)], LINE, 100)
expect(out).toHaveLength(2)
})
it('FE-ROADTRIP-REFUEL-012: two operators sharing one services are both offered', () => {
// Position alone is not identity either.
const out = reachableRefuels([at('Aral', 52, 13.3), at('Shell', 52.0003, 13.3)], LINE, 100)
expect(out.map(p => p.name).sort()).toEqual(['Aral', 'Shell'])
})
})
@@ -0,0 +1,123 @@
import { haversineKm, projectOntoRoute, type LatLng } from './corridor'
import type { Poi } from '../Map/poiCategories'
/**
* Picking a filling station for a tank that is about to run out.
*
* Pure: no React, no network. The caller does the one request and hands the answers
* here, which keeps the judgement — which of these can the car actually reach — testable
* without a server.
*/
/** A candidate, with the two figures that decide whether it is any use. */
export interface RefuelCandidate extends Poi {
/** Kilometres along the day's driving line, in the same space as the dry point. */
alongKm: number
/** How far off the road it sits. */
offRouteKm: number
/** Kilometres of range still in the tank when the car draws level with it. */
spareKm: number
}
/**
* How much of the last drop to keep back, in kilometres.
*
* Arriving at a pump with nothing left is not a plan, and the range figure a traveller
* types is a round number from a manual rather than a measurement. Ten kilometres is
* small enough not to move the suggestion and large enough not to promise the
* impossible.
*/
const RESERVE_KM = 10
/**
* The stations that can actually be reached, nearest to the dry point first.
*
* Three filters, and each one exists because its absence produces a wrong answer rather
* than an untidy one:
*
* - Only what lies BEFORE the point the tank empties. A station 20 km past it is on the
* far side of an empty tank, and offering it is offering a walk.
* - The detour counts double. A pump 8 km off the road is 16 km of driving, and on a
* tank this close to empty that is the difference between arriving and not.
* - What is left has to fit in the range that remains, minus a reserve.
*
* Sorted by the detour, not by how far along the road it sits. Measured against a real
* day: the station that got furthest was 9.2 km off the road for one kilometre more of
* range, while one 0.9 km off sat almost as late. Everything in this list is already
* reachable, so what separates them is what the stop costs, and that is the detour. Ties
* go to the later one, because filling up early wastes the tank you already paid for.
*/
export function reachableRefuels(
pois: Poi[],
line: LatLng[],
dryAlongKm: number,
{ existing = [] }: { existing?: { lat: number; lng: number }[] } = {},
): RefuelCandidate[] {
const out: RefuelCandidate[] = []
for (const poi of pois) {
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, line)
if (!hit) continue
// Reached before the tank empties, once the detour out and back is paid for.
const spareKm = dryAlongKm - hit.alongKm - hit.offRouteKm * 2 - RESERVE_KM
if (spareKm < 0) continue
// Already planned. Matched on position rather than on id: the same pump added by
// hand, or from another source, carries a different osm_id and would otherwise be
// suggested next to itself.
if (existing.some(e => haversineKm(e, { lat: poi.lat, lng: poi.lng }) < 0.15)) continue
out.push({ ...poi, alongKm: hit.alongKm, offRouteKm: hit.offRouteKm, spareKm })
}
return oneEach(out.sort((a, b) => a.offRouteKm - b.offRouteKm || a.spareKm - b.spareKm))
}
/**
* What a search actually established, as opposed to what it returned.
*
* An empty list means one of two very different things, and saying the wrong one is
* worse than saying nothing: either there is no filling station on this stretch, or we
* never managed to look. The server flags the second case itself (`truncated` when it
* hit its own ceiling, `clamped` when it narrowed the area), and a failed request is the
* third. Only `none` is a statement about the road.
*/
export type RefuelOutcome = 'found' | 'none' | 'incomplete' | 'failed'
export function outcomeOf(
candidates: RefuelCandidate[],
answer: { truncated?: boolean; clamped?: boolean } | null,
): RefuelOutcome {
if (!answer) return 'failed'
if (candidates.length) return 'found'
// `truncated` alone. `clamped` only means the server searched a smaller circle than
// the box described, which the caller now sizes so it never happens — and reporting it
// as "we could not check" turned every empty answer into a shrug.
return answer.truncated ? 'incomplete' : 'none'
}
/**
* One entry per filling station, not one per pump.
*
* A charging site is mapped in OSM as a node per socket and a forecourt often as one per
* pump, all with the same name a few metres apart. Deduplicating by id keeps every one of
* them, which is right on a map and wrong in a list of three offers: measured on a real
* day, the top three were the same "autostrom plus GmbH" three times over.
*
* Same name and within half a kilometre counts as the same place. Both halves matter:
* not position alone, because two operators do share a motorway services, and not name
* alone, because a chain has a branch in every town.
*
* Half a kilometre comes from the data rather than from taste. In one 25 km search the
* four nodes of one charging site sat in two pairs, 6 m and 7 m apart, with 450 m between
* the pairs — while genuinely different branches of the same chain were 18 to 36 km
* apart. Anything from 500 m to a few kilometres separates those two cases; the lower end
* is the safer place to stand.
*/
const SAME_PLACE_KM = 0.5
function oneEach(sorted: RefuelCandidate[]): RefuelCandidate[] {
const kept: RefuelCandidate[] = []
for (const poi of sorted) {
const twin = kept.some(k =>
k.name === poi.name && haversineKm(k, { lat: poi.lat, lng: poi.lng }) < SAME_PLACE_KM)
if (!twin) kept.push(poi)
}
return kept
}
@@ -11,6 +11,7 @@ import {
parseClock,
splitIntoRuns,
sumLegSeconds,
refuelStopTypeFor,
} from './roadtripModel'
describe('formatDurationShort', () => {
@@ -89,7 +90,10 @@ describe('computeSchedule', () => {
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', () => {
it('works back from the first pinned time to say when to set off', () => {
// The museum opens at ten and we want an hour at the stop before it, an hour of
// driving in between: be there at eight. Answering that is the reason to pin a time
// on the second stop at all.
const { entries } = computeSchedule(
[
{ anchor: null, dwellMinutes: 60 },
@@ -97,10 +101,41 @@ describe('computeSchedule', () => {
],
[hours(1)],
)
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '09:00', anchored: false })
expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
})
it('stops working back at a leg that never routed rather than inventing one', () => {
const { entries } = computeSchedule(
[
{ anchor: null, dwellMinutes: 60 },
{ anchor: null, dwellMinutes: 30 },
{ anchor: '10:00', dwellMinutes: 30 },
],
[undefined, hours(1)],
)
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
// Leaves at 09:00 to arrive at 10:00, and its own half hour puts it there at 08:30.
expect(entries[1]).toMatchObject({ arrival: '08:30', departure: '09:00' })
expect(entries[2]).toMatchObject({ arrival: '10:00', anchored: true })
})
it('keeps the earliest stop on day zero when working back crosses midnight', () => {
// Pinned at one in the morning with three hours of driving before it: the stop before
// is the evening before. The chain shifts up a day rather than printing a day below
// zero, so the boundary shows between the two stops instead of under the first one.
const { entries, warnings } = computeSchedule(
[
{ anchor: null, dwellMinutes: 0 },
{ anchor: '01:00', dwellMinutes: 0 },
],
[hours(3)],
)
expect(entries[0]).toMatchObject({ arrival: '22:00', dayOffset: 0 })
expect(entries[1]).toMatchObject({ arrival: '01:00', dayOffset: 1, anchored: true })
expect(warnings).toContainEqual({ index: 1, code: 'overnight' })
})
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.
@@ -545,3 +580,234 @@ describe('deriveDriveWarnings — what counts as driving', () => {
expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }])
})
})
describe('deriveDriveWarnings — where the tank actually runs dry', () => {
const drive = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
const ferry = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'ferry' })
it('FE-ROADTRIP-MODEL-089: the dry point sits where the fuel ends, not where somebody notices', () => {
// 300 km of range, two legs of 200. The warning lands on stop 2, which is 400 km in;
// the tank was empty 100 km earlier, halfway through the second leg. Suggesting a
// filling station at the warning would suggest one the car cannot reach.
const out = deriveDriveWarnings(
[drive(120, 200), drive(120, 200)],
[false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(out.warnings).toEqual([{ index: 2, code: 'range', sinceKm: 400 }])
expect(out.emptyAt).toEqual([
{ legIndex: 1, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 },
])
})
it('FE-ROADTRIP-MODEL-090: one dry point per tank, however many warnings the stretch produces', () => {
// A long run with nothing on it warns at every stop on purpose, because a warning is
// not a fill-up. One refuel offer per warning would stack three identical offers for
// a single tank down one day.
const out = deriveDriveWarnings(
[drive(60, 400), drive(60, 400), drive(60, 400)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 600 },
0,
)
expect(out.warnings).toHaveLength(2)
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, drivenMeters: 600000 })
})
it('FE-ROADTRIP-MODEL-091: filling up starts a new tank, and the next one runs dry again', () => {
const out = deriveDriveWarnings(
[drive(60, 400), drive(60, 400), drive(60, 400)],
[false, false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 600 },
0,
)
// Empty once before the fuel stop, then once more after it.
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0].drivenMeters).toBe(600000)
})
it('FE-ROADTRIP-MODEL-092: a ferry carries the car without burning a drop', () => {
// The trap this field exists for. The budget skips a ferry, so the dry point must be
// counted in DRIVING metres only; measuring along the day's drawn line instead would
// overshoot by the whole crossing and put the marker out at sea.
const out = deriveDriveWarnings(
[drive(60, 200), ferry(120, 50), drive(60, 200)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(out.emptyAt).toEqual([
{ legIndex: 2, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 },
])
})
it('FE-ROADTRIP-MODEL-093: a tank carried over midnight runs dry earlier the next day', () => {
const out = deriveDriveWarnings(
[drive(60, 200), drive(60, 200)],
[false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
250,
)
// Only 50 km left on arrival, so it empties a quarter into the first leg.
expect(out.emptyAt).toEqual([
{ legIndex: 0, intoLegKm: 50, drivenMeters: 50000, sinceKm: 300 },
])
})
it('FE-ROADTRIP-MODEL-094: no range limit and no crossing produce no dry point at all', () => {
const noLimit = deriveDriveWarnings(
[drive(60, 900)],
[false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: null },
0,
)
expect(noLimit.emptyAt).toEqual([])
const withinRange = deriveDriveWarnings(
[drive(60, 100)],
[false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(withinRange.emptyAt).toEqual([])
})
it('FE-ROADTRIP-MODEL-095: an unrouted leg gives the tank up rather than guessing where it ends', () => {
// The budget goes null and stays null, so there is no honest dry point to offer.
const out = deriveDriveWarnings(
[drive(60, 200), undefined, drive(60, 400)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(out.emptyAt).toEqual([])
expect(out.carryKm).toBeNull()
})
})
describe('refuelsRange — what fills which tank', () => {
it('FE-ROADTRIP-MODEL-096: with no vehicle named, either kind fills up', () => {
// What TREK did before the setting existed, and the right answer for somebody who
// never opened the dialog. Anything else would quietly change their warnings.
expect(refuelsRange('fuel')).toBe(true)
expect(refuelsRange('charging')).toBe(true)
expect(refuelsRange('fuel', null)).toBe(true)
expect(refuelsRange('charging', null)).toBe(true)
})
it('FE-ROADTRIP-MODEL-097: a petrol station does not charge a battery', () => {
// The bug this exists for: an electric car pausing at a petrol station had its
// battery refilled on paper, the warnings went quiet for the rest of the day, and
// the driver was told nothing.
expect(refuelsRange('charging', 'electric')).toBe(true)
expect(refuelsRange('fuel', 'electric')).toBe(false)
})
it('FE-ROADTRIP-MODEL-098: and a charger does not fill a tank', () => {
expect(refuelsRange('fuel', 'combustion')).toBe(true)
expect(refuelsRange('charging', 'combustion')).toBe(false)
})
it('FE-ROADTRIP-MODEL-099: nothing else refuels anything, whatever is driven', () => {
for (const vehicle of [null, 'combustion', 'electric'] as const) {
expect(refuelsRange('rest_area', vehicle)).toBe(false)
expect(refuelsRange('restaurant', vehicle)).toBe(false)
expect(refuelsRange(null, vehicle)).toBe(false)
}
})
it('FE-ROADTRIP-MODEL-100: the search looks for what the vehicle actually takes', () => {
expect(refuelStopTypeFor('combustion')).toEqual(['fuel'])
expect(refuelStopTypeFor('electric')).toEqual(['charging'])
expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging'])
})
})
describe('deriveDriveWarnings — filling only part way', () => {
const drive = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
it('FE-ROADTRIP-MODEL-101: a stop that fills to 80 % leaves a fifth already used', () => {
// Nobody charges to 100 % on the road: the last fifth takes as long as the first
// four. Counting a stop as a full tank overstates what comes after it by that fifth.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
)
// After the stop the budget restarts at 100 km rather than 0, so the tank is dry
// 400 km into the second leg. Filled all the way it would have gone the whole 450.
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 })
})
it('FE-ROADTRIP-MODEL-102: filling all the way is what absent, zero and 100 all mean', () => {
const legs = [drive(60, 100), drive(60, 450)]
const refuels = [false, true, false]
const full = { legMinutes: null, dayMinutes: null, rangeKm: 500 }
for (const fillPercent of [undefined, null, 0, 100]) {
const out = deriveDriveWarnings(legs, refuels, { ...full, fillPercent }, 0)
// A full tank after the stop covers the remaining 450 km without a finding.
expect(out.emptyAt).toEqual([])
expect(out.warnings).toEqual([])
}
})
it('FE-ROADTRIP-MODEL-103: with no range set, the fill level changes nothing', () => {
const out = deriveDriveWarnings(
[drive(60, 900)],
[true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: null, fillPercent: 50 },
0,
)
expect(out.warnings).toEqual([])
expect(out.emptyAt).toEqual([])
})
it('FE-ROADTRIP-MODEL-104: a stop that says how full it fills is read against itself', () => {
// The motorway charger tops up to 60 %; the traveller's own default is 80. Reading
// the default here would promise 100 km the car does not have.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
[null, 60, null],
)
// 60 % of 500 km is 300, so the tank is dry 300 km into the second leg rather than
// the 400 the traveller's own figure would have given.
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 300 })
})
it('FE-ROADTRIP-MODEL-105: a stop with no opinion still follows the traveller', () => {
// Same day, same stop, nothing said about it: the default has to keep applying, or
// adding the field would have quietly changed every trip that never used it.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
[null, null, null],
)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 })
})
it('FE-ROADTRIP-MODEL-106: a stop can also say it fills right up', () => {
// The charger at the hotel: the car stands there all night, so this one goes to 100
// even on a trip whose default stops at 80.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
[null, 100, null],
)
expect(out.emptyAt).toEqual([])
})
})
+185 -23
View File
@@ -4,6 +4,8 @@
* on its own and reused by the map, the rail and (later) the MCP tool.
*/
import type { RouteAvoidClass } from '../../types'
/**
* The stop kinds that interrupt a drive rather than end it.
*
@@ -121,9 +123,32 @@ export interface ScheduleWarning {
*/
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 ?? '')
/**
* What the traveller drives, which decides what actually fills the tank.
*
* Absent means both count, which is what TREK did before the setting existed and is the
* right answer for somebody who never said. It is only wrong once a vehicle IS named:
* a petrol station does not charge a battery, and a charger does not fill a tank.
*/
export type VehicleKind = 'combustion' | 'electric'
/** The stop kind that refills the named vehicle, for filtering a search. */
export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] {
if (vehicle === 'combustion') return ['fuel']
if (vehicle === 'electric') return ['charging']
return REFUELLING_STOP_TYPES
}
/**
* Whether stopping here starts the range budget over.
*
* With no vehicle named, either kind does — a plain reading of "I filled up". Once one is
* named the other stops counting, because the alternative is arithmetic that is simply
* wrong: an electric car pausing at a petrol station had its battery refilled on paper,
* the warnings went quiet for the rest of the day, and the driver was told nothing.
*/
export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean {
return refuelStopTypeFor(vehicle).includes(stopType ?? '')
}
/**
@@ -131,6 +156,15 @@ export function refuelsRange(stopType: string | null | undefined): boolean {
* rather than a sentinel: zero is a legal thing to type and means the same as unset.
*/
export interface DriveLimits {
/**
* How full a fill-up actually goes, 1 to 100, or null for "all the way".
*
* Nobody charges an electric car to 100 % on the road: the last fifth takes as long as
* the first four and the manual says not to. Treating every stop as a full tank
* overstates the range after it by exactly that fifth, which is a whole leg on a long
* day.
*/
fillPercent?: number | null
/** Longest single drive between two stops, in minutes. */
legMinutes: number | null
/** Longest total driving in one day, in minutes. */
@@ -212,8 +246,10 @@ function resolveArrival(
}
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
const entries: ScheduleEntry[] = []
const warnings: ScheduleWarning[] = []
// Arrivals in minutes, so the chain can be walked twice before anything is formatted.
const arrivals: (number | null)[] = new Array(stops.length).fill(null)
const anchored: boolean[] = new Array(stops.length).fill(false)
// Minutes since the first stop's midnight, so a chain crossing midnight keeps counting.
let cursor: number | null = null
let dayOffset = 0
@@ -224,28 +260,57 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset)
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy })
if (arrival === null) {
if (arrival === null) continue
const offset = Math.floor(arrival / DAY_MINUTES)
if (offset > dayOffset) dayOffset = offset
arrivals[i] = arrival
anchored[i] = anchor !== null
const leg = legSeconds[i]
cursor = leg === undefined ? null : arrival + (stop.dwellMinutes ?? 0) + Math.round(leg / 60)
}
// Then backwards, for the stops the forward walk left blank because nobody had pinned
// a time yet. Pinning a time on the second stop is the ordinary way to plan: the museum
// opens at ten, so when do we have to leave? Working back from the first known arrival
// answers it — departure = the next arrival minus the drive, arrival = that minus the
// stay — and it stops at the first leg that never routed rather than inventing one.
const firstKnown = arrivals.findIndex(a => a !== null)
for (let i = firstKnown - 1; i >= 0; i--) {
const leg = legSeconds[i]
const next = arrivals[i + 1]
if (leg === undefined || next === null) break
arrivals[i] = next - Math.round(leg / 60) - (stops[i].dwellMinutes ?? 0)
}
// Working back can land before the anchor day's midnight, which would print as a
// negative day. `dayOffset` counts days past the FIRST stop, so the whole chain shifts
// up instead until the earliest stop sits on day zero again.
const earliest = arrivals.reduce<number | null>((m, a) => (a === null ? m : m === null || a < m ? a : m), null)
const shift = earliest === null || earliest >= 0 ? 0 : -Math.floor(earliest / DAY_MINUTES) * DAY_MINUTES
const entries: ScheduleEntry[] = []
let lastOffset = 0
for (let i = 0; i < stops.length; i++) {
const raw = arrivals[i]
if (raw === 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
const arrival = raw + shift
const offset = Math.floor(arrival / DAY_MINUTES)
// Read off the finished chain rather than during the forward walk, so a midnight the
// backward pass introduced is marked too.
if (offset > lastOffset) warnings.push({ index: i, code: 'overnight' })
lastOffset = offset
entries.push({
arrival: formatClock(arrival),
departure: formatClock(departure),
anchored: anchor !== null,
departure: formatClock(arrival + (stops[i].dwellMinutes ?? 0)),
anchored: anchored[i],
dayOffset: offset,
})
const leg = legSeconds[i]
cursor = leg === undefined ? null : departure + Math.round(leg / 60)
}
return { entries, warnings }
@@ -308,6 +373,29 @@ export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
* 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.
*/
/**
* Where a tank runs dry, in the day's own driving coordinates.
*
* A separate thing from the range WARNING, and the difference is the whole point. The
* warning is filed against the stop the car arrives at, which is where somebody finds
* out; this is where the fuel actually runs out, which can be most of a leg earlier and
* is the only place worth suggesting a filling station.
*
* `drivenMeters` counts DRIVING legs only, so it is not an offset into the day's drawn
* line: that line also carries ferry and walking runs. Converting it to a coordinate
* means walking the driving legs, which `dryPointOn` does.
*/
export interface DryPoint {
/** Index into the day's legs, i.e. the leg the car is on when the tank empties. */
legIndex: number
/** How far into that leg, in kilometres. */
intoLegKm: number
/** Metres of driving from the start of the day to that point. */
drivenMeters: number
/** The range limit that was crossed, which is what the traveller set. */
sinceKm: number
}
/**
* 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.
@@ -344,13 +432,45 @@ export function deriveDriveWarnings(
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 } {
/**
* Per stop, how full THAT stop fills up, 1-100. Absent follows `limits.fillPercent`.
*
* A property of the stop rather than of the traveller: a motorway rapid charger is
* worth about 80 % because the last fifth costs as long again, while the one at the
* hotel is worth all of it because the car stands there all night. One figure for the
* whole trip cannot say both, and the difference between them is a leg.
*
* Last and optional so the four-argument call, which is every caller that has no
* per-stop opinion, keeps meaning exactly what it did.
*/
fillAt: readonly (number | null | undefined)[] = [],
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null; emptyAt: DryPoint[] } {
const warnings: ScheduleWarning[] = []
const emptyAt: DryPoint[] = []
let budget = carryKm
let totalSeconds = 0
// Metres of DRIVING covered so far. Not the same as metres along the day's line: the
// line carries every run, and a ferry or a walk in the middle of a day adds to it
// without adding to this. Whatever converts a dry point back into a coordinate has to
// walk the driving legs only, which is why this is counted here rather than derived.
let drivenMeters = 0
/**
* What is already on the clock the moment the car pulls away from stop `i`.
*
* A tank filled to 80 % has used a fifth of its range before it moves, which is how a
* budget that counts upwards says "not full". Zero, absent and 100 all mean it filled
* right up, so all three land on nothing used.
*/
const usedAfterFilling = (i: number): number => {
const percent = fillAt[i] ?? limits.fillPercent
return limits.rangeKm && percent && percent > 0 && percent < 100
? limits.rangeKm * (1 - percent / 100)
: 0
}
for (let i = 0; i < legs.length; i++) {
if (refuelsAt[i]) budget = 0
if (refuelsAt[i]) budget = usedAfterFilling(i)
const leg = legs[i]
// The stop this leg arrives at. Both findings are about what is true on arrival.
const at = i + 1
@@ -370,7 +490,25 @@ export function deriveDriveWarnings(
if (typeof metres !== 'number') {
budget = null
} else if (budget !== null) {
const before = budget
budget += metres / 1000
// Where the tank actually runs dry, as opposed to where somebody notices. The
// warning below sits on the arriving stop, which can be a hundred kilometres past
// the point the fuel ran out; a suggestion has to be offered at the point, or it
// suggests filling up somewhere the car cannot reach.
//
// Once per tank, not once per warning. A long stretch with nothing on it produces
// a run of warnings on purpose (see the note above), and one refuel offer per
// warning would stack three identical offers down one day for a single fill-up.
if (limits.rangeKm && before <= limits.rangeKm && budget > limits.rangeKm) {
const intoLegKm = limits.rangeKm - before
emptyAt.push({
legIndex: i,
intoLegKm,
drivenMeters: drivenMeters + intoLegKm * 1000,
sinceKm: Math.round(limits.rangeKm),
})
}
// 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
@@ -378,18 +516,19 @@ export function deriveDriveWarnings(
if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) {
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
}
drivenMeters += metres
}
}
// 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
if (refuelsAt[legs.length]) budget = usedAfterFilling(legs.length)
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 }
return { warnings, day, carryKm: budget, emptyAt }
}
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
@@ -639,3 +778,26 @@ export function reanchorAfterReorder(
export function isEmptyReanchoring(r: Reanchoring): boolean {
return r.vias.length === 0 && r.remove.length === 0
}
/**
* The road classes a stored setting asks to leave out, validated.
*
* Parsed rather than trusted. A per-user setting has no server-side validation at all —
* the write route stores any key with any value and says so — so an unknown word here
* would travel straight into a costing option the router does not have. Unknown entries
* are dropped, the order is fixed so two equal settings produce one cache key, and
* anything that is not a non-empty list reads as "route normally".
*/
export function parseAvoid(raw: unknown): RouteAvoidClass[] {
if (typeof raw !== 'string' || !raw.trim()) return []
const asked = new Set(raw.split(',').map(part => part.trim().toLowerCase()))
return AVOIDABLE.filter(cls => asked.has(cls))
}
/** Every class that can be avoided, in the order they are offered and stored. */
export const AVOIDABLE: readonly RouteAvoidClass[] = ['toll', 'motorway', 'ferry']
/** The setting value for a set of classes, in the fixed order. */
export function serializeAvoid(classes: readonly RouteAvoidClass[]): string {
return AVOIDABLE.filter(cls => classes.includes(cls)).join(',')
}
@@ -30,8 +30,8 @@ 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 },
{ assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, ownerDayId: 1, ownerIndex: 1, 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]],
@@ -41,7 +41,7 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
}
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
days, lines: [], lineDays: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
})
function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {

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