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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
The schemas had no spec of their own. The two things worth pinning are the
ones the feature rests on: the anchor is a position and never an id, so a
fraction or a negative is not a leg of anything; and the batch route's
track has three states, because absent and null mean different things to a
day that already follows a line.
The road trip handlers in useTripPlanner are unreachable until the addon is
on for the instance and the mode is on for the trip, so the existing suite
never entered them: 360 of the file's new lines and branches had never run.
Turns both switches on and drives what the rail, the corridor panel and the
map call. The cases that matter are the ones where a via has to be
re-anchored: a via records which stop it follows by position, so a reorder,
a move between days or an insert that forgets the correction silently
re-shapes the drive.
Five files, about 570 lines, that shipped without one. The cases are chosen for
what a later change could break quietly rather than for the line count.
useRouteAlternatives carries the decision the feature rests on: when a leg
already has vias, the road actually being driven is NOT among what the router
offers for the two bare endpoints, because it was asked a different question. It
is fetched separately and put at the top — without that, opening the picker on a
leg you have reshaped shows three roads, none of which is the one you are on and
no way back to it. Also pinned: a via that barely moves the route produces one
entry rather than two, a second ask abandons the answer still in flight, and only
driving, walking and cycling ever reach the router.
RoadtripStayModal writes `places.duration_minutes`, which the schedule has read
for years and nothing could write. The case that matters is that clearing sends a
zero and never a null: the update statement folds a null into "leave it alone",
so a null could give a stop a stay and never take one away. The clock cases pin
the thing a slider is for over a text box, which is watching the departure move.
StopKindPicker is a portal, so it gets the cases a portal makes easy to get
wrong: nothing without an anchor, outside-click and Escape both closing, the
anchor itself not counting as outside, and picking the kind a stop already has
taking it back off rather than setting it twice.
RoadtripCategoryPicker stays open on a click, which is the whole reason it is
multi-select, and names the kinds rather than counting them.
RoadtripAlternativesBar reports each offer by its own index rather than by its
position — a position drifts the moment the driven road is prepended — and shows
loading, failed and "only one way" as three different answers instead of an
empty list.
Four small things, and one that was not this branch's fault but is now its
neighbour.
The driving limits saved on every keystroke. Typing "180" sent three settings
writes carrying 1, 18 and 180, unordered and concurrent over HTTP/2, so
whichever the server committed last won: the number that decides every
over-budget warning could quietly end up a tenth of what was typed, and only
show it after the next reload. Typed locally now, committed on blur or Enter,
and a value that did not actually change writes nothing.
The empty-result message always blamed the name box. Four controls narrow that
list and only one of them is the name, so picking a section with no hits
produced `Nothing on the way matches ""` — empty quotes naming a filter the
reader never set. The count line above it was gated the same way, so the section
case lost its "x of y" as well. Both follow whatever actually narrowed the list.
The check-out dropdown printed a bare ISO date, the only place in the planner
that did; it goes through the same helper as the rail beside it.
Switching an addon off left open MCP sessions holding its write tools. A `when:`
gate runs once per session, at attach time, so the tools stay registered and
callable while the REST half answers 404 for the same user in the same moment —
and the list that decides whether to tear those sessions down had drifted:
airtrail and collections gate tools and were missing from it, which this addon
would have inherited. The list now sits beside ADDON_IDS and a parity test reads
the gates out of the source and holds it there. Read out of the source rather
than out of a runtime registry on purpose: a runtime set only holds the gates
whose modules the caller happened to import, so it would pass in a suite that
imports none — which is the shape of test that hid this in the first place.
RoadtripLimitsCard gets the test it never had, including the one case that
matters: three keystrokes are not three writes.
Five things, all of them a click or a drag doing something the traveller did not
ask for.
The GL via handle committed a move on every pointerup. Any button started the
drag and there was no movement threshold, so a plain click wrote the via to
wherever inside the 17px handle the pointer happened to be — at a low zoom that
is kilometres, and the write re-routes the whole trip. A right-click ran the
drag AND the delete, so one gesture sent two writes; on the platforms where
contextmenu comes first, the move landed on an id that no longer existed and
reported a failure for a deletion that had worked. Leaflet gets this right for
free from `dragend`, which is why the two renderers behaved differently for the
same gesture. Primary button only now, with a threshold, and pointercancel puts
the pin back instead of committing — the browser fires it when the captured
element is torn out of the DOM, which is exactly what a delete does.
With the alternatives picker open, one click on the route fired both handlers.
Layer registrations are independent in MapLibre, and every alternative starts
and ends at the same two stops — when the leg already carries vias the current
road is itself one of the offers, so the overlap is the whole leg. The click
dropped a via AND chose a different road: two writes and two trip reloads for a
gesture made once.
The corridor search could be started before the day had routed. The corridor is
then built from the straight line between the stops rather than the roads
driven, and the routing answer landing a second later changes the line, which
clears the search mid-flight and drops every result — no error, no partial
result, just the idle empty state again. Routing runs one day at a time about a
second apart, so the window is wide open on a long trip.
Leaving road trip mode left the alternatives drawn on the map. The switch is in
the sidebar and reachable while the bar is open, and the overlay depends only on
the picker, so pale blue routes with drive-time pills stayed on an ordinary
planner map with no road trip UI left to dismiss them from.
Clearing a leg's vias swallowed its failure and closed the picker anyway. Every
neighbouring write in that hook reports one. The leg still carried its via and
still routed the old way, so the traveller believed they had undone the detour.
Walking and cycling legs were counted as driving. A day may legitimately mix
modes — splitIntoRuns exists for that — and a two hour hike was reported as
"over the longest drive allowed", its minutes went into the day's driving time
and its kilometres onto the tank, where a ferry crossing could trip a range
warning outright. A leg with no mode still counts, because every leg was a drive
before the field existed.
The road-trip domain also says out loud that it is online-only, which the repo
asks for rather than leaving it to be discovered. A via anchors by POSITION, so a
queued write replayed after somebody else moved a stop would re-pin the drive
onto a leg nobody chose, silently and hours later; the offline story needs the
anchor to be an assignment id first. Until then the surfaces disable the gesture
instead of accepting one and dropping it.
Six theme-lint markers move onto the line they exempt. The linter suppresses per
line, so a marker in the comment block above suppressed nothing and the six
road-signage colours the authors documented as intentional were counted anyway.
The field was rendered in every non-managed user's own Map settings and written
to their personal settings row. The CSP is built from something else: bootstrap
reads the ADMIN default and puts that origin into `connect-src`. So filling the
field in was the one path the product offered, and it could not work — the route
calculator happily built its URLs against the typed origin and the browser
refused every one of them with a bare TypeError, not a status, so no fallback and
no retry could see it. Day-plan routes, transport legs, the mobile day route,
Studio's road route and the whole road-trip rail stopped routing, app-wide, with
a generic "route could not be calculated". Nothing in the UI could repair it
except emptying the field again, and there was no admin field to set the value
that would have made the origin allowed.
The repo has decided this shape once already: `llm_base_url` is admin-only
because it names where the server sends its own requests and only whoever runs
the instance can judge what is reachable. This is the same class for a different
reason, so it joins the same guard, which is now named for what it actually
covers. Clearing stays open to everyone, so a row written before the rule cleans
itself up rather than blocking the save.
The field moves to the admin's default-user settings, next to the tile URL and
the Carto key, where a value takes effect.
The domain arrived with 557 lines, a service test, and nothing else. No
controller test, no e2e, and no MCP test — so the guard chain, the trip scoping
on every route, the cross-trip track check and the day_edit parity were all
unasserted, and the whole domain sat in the 80 catch-all where it could shed
twenty points against the headroom of the domains beside it.
The e2e is the one that earns its place. `@RequireAddon` is metadata and does
nothing on its own, and AddonGuard is not global, so the ordering the
controller's own comment calls load-bearing is exactly the kind of thing a later
change removes without noticing — it had already happened once. Deleting the
guard now turns three cases red, which I checked by deleting it.
The MCP tools had never executed at all: the shared harness seeds the addon
disabled, so `when: roadtripAddonOn` kept all five from attaching. They now run
against the same checks the REST routes make, including the demo account and a
member without day_edit, because the repo requires the two surfaces to move
together.
Two client modules that had no test get one: the overlay builder, where two
decisions live that are not cosmetic — which offered road is drawn as the one you
are on, and that a label hangs where its own route is unlike every other, rather
than at a midpoint that can fall on a stretch two routes share — and the service
marker, where the thing worth pinning is that an ordinary place gets nothing
back, and that the anchor arithmetic includes the border.
The domain carries its own coverage floor now instead of hiding in the catch-all.
Pinned under the measured figure on purpose; the exact ratchet gets regenerated
with the repo's own script after a full run.
Along the way, roadtrip_vias and roadtrip_day_tracks join the reset list every
other domain's tables are on. Without that a via written by one case was still
there for the next one, which is how the first version of the MCP suite passed
an assertion it should have failed.
A via records which stop it follows by POSITION, so anything that renumbers a
day's stops moves the ground under it. The road-trip rail corrects for that;
almost nothing else did.
reanchor() never renumbered `sequence`. It is allocated per leg and starts at 0
in each, and re-anchoring MERGES two legs whenever the stop between them goes
away — so leg A's 0,1 ended up beside leg B's 0,1, and everything that draws the
route orders by sequence. The drive ran A0, B0, A1, B1: forward, back, and
forward again. Persisted, surviving a reload, and unrepairable except by deleting
the vias. The old anchor is read before the updates and used as the primary sort
key, so the vias of the earlier leg stay ahead of the ones that merged into it.
Reordering the only two stops of a day deleted every via on it. A reorder is
modelled as remove-then-insert, and the removal half refuses to treat a two-stop
day as still having a drive — right when a stop really goes away, catastrophic
when nothing was removed. Two stops is exactly the shape "follow this track"
produces, up to nine vias on that one leg, gone to a single drag with no prompt
and no undo.
Removing a stop, reordering within a day, and assigning a place mid-day never
re-anchored at all, though the helpers exist and three other paths use them. The
detour then sat on a leg the traveller never chose, or on a leg that no longer
existed and vanished — until a later stop brought it back bending a road nobody
asked for. Reordering hands a whole new ordering rather than one move, so the
anchors there follow the stop they were pinned behind instead of arithmetic on
the index, which is correct for any permutation.
Duplicating a trip dropped all of it: `stop_type` was missing from the place
insert, and neither via nor day-track table was copied. The duplicate looked
complete and quietly drove somewhere else.
A failed read of the via list emptied it. That read runs after every write and
feeds the routing key, so a 502, a dropped connection or a tab going offline
re-routed the whole trip along the roads the traveller had steered away from,
with nothing said and nothing retried — indistinguishable from having deleted
them. Only a 404 or 403 empties now; anything else keeps the last known list and
says it may be stale.
Placing one triggers a re-route, the re-route lands about a second later, and
the render it causes rebuilt every via handle — which is exactly when somebody
reaches for the dot they just made. On the GL renderer that rebuild replaces
the DOM element outright, and a pointerdown belongs to the element it happened
on, so the drag went to an element that no longer existed. The second attempt
worked because by then nothing was pending, and an older via never showed it
because nothing was re-routing.
Two causes, both identity: the vias arrive as a fresh object on every reload
even when the rows came back identical, and `onMoveVia` is a fresh closure on
every render of the planner. Both were in the effect's dependencies.
The list is now keyed by what a handle is built from — id and position — so it
only changes when a via does, and the callbacks are read through a ref. A via
that genuinely moved still gets a new list, which is what redraws it where it
now is; the test pins both halves, because holding the element still must not
mean holding it in the wrong place.
Leaflet's markers had the same churn without the same symptom: a fresh
`position` array called `setLatLng` on every marker on every render, and a
fresh `eventHandlers` object unbound and rebound every listener. Same fix.
The vias already made the day follow the road; nothing said which road. After a
reload the badge could only say "shaped by hand", and re-fitting after the stops
changed meant finding the track again by eye.
One row per day, written in the same transaction as the chain it describes: a
day claiming to follow a road whose vias never landed would be worse than a day
claiming nothing. Three states rather than two, which is why the field is an
object and not an id — absent leaves whatever the day followed alone, null says
it follows nothing, an object records the road it now takes. A bare nullable id
could not tell "do not touch this" apart from "forget it", and a hand-dragged
via on the same day must not wipe the label.
The foreign key is the point: delete the imported track and the label cascades
with it, so no day is left claiming to follow a line nobody can see. The vias
stay, because they are the drive.
Both write paths check that the place is a track of this trip before it becomes
a label — permission alone would let another trip's place id, or an ordinary
place, become a name that can never be drawn.
Also clears three reliability findings the scanner raised on this branch: two
default sorts given an explicit comparator, and a trailing-slash regex the
scanner is right to call backtracking, walked instead.
Pasting a /maps/dir/ link into the list-import box answered "could not
extract list ID", which is the whole of the complaint: people share a drive
far more often than they share a list, and it is the same gesture — somebody
pressed Share in Google Maps. Which screen they were on is the URL's business,
so the box takes both and the controller decides.
No API key and no call to Google for the link itself: the stops are in the URL.
Three shapes arrive — the path form with its data blob, the documented
?api=1&origin= form, and a short link that is one of those after a redirect —
and a stop the link spells out in coordinates needs no geocoder at all. One
that is only a name is geocoded, keeping the name from the link rather than
the one the geocoder answers with: somebody who typed a nickname into Google
should not find a street address on their trip.
A name nobody can place is left out and counted rather than failing the
import, because a route of six stops with five findable is five stops more
than the traveller had. Coordinates are read as 1d/2d, longitude before
latitude, which is the reverse of a place link and the one way to put Berlin
in Somalia. A blob that does not line up with the stops is ignored entirely:
half a link read confidently is worse than one read as names.
An imported GPX track was a line drawn beside the day: the map showed the
scenic route while the day kept driving the motorway, so the distance, the
drive time and every warning belonged to a road nobody was taking. Norway
publishes its scenic routes that way, and so does every touring club worth
importing from.
A track becomes a route the day takes by refinement, not by conversion. Route
through what we have, find the point of the track the road misses by the most,
make that point a via, go again. Each round roughly halves the worst miss,
because the new via lands in the middle of what the last one left unfollowed,
so a scenic route usually fits inside ten vias — and the loop stops when the
road is close enough rather than after a fixed count. Every point of the track
would route exactly and take a hundred requests; two would route in seconds
and follow nothing.
The day is routed whole per round, not leg by leg: it is a single request
already, and per-leg would multiply every round by the number of legs against
a host that answers one request a second. The track is oriented to the day
first, because half of Norway's routes are published southbound, and the legs
it covers are cleared before the new chain lands, so applying a track twice
does not stack.
Offered from the day's own badges, and only once something has been imported.
Tinted while the day carries vias: the rail draws none of them, so that badge
is the only place a drive shaped by hand differs from one the router picked.
A nowrap label reports its whole width as its min-content contribution, so the
place-name select sat at its default `min-width: auto` and refused to shrink —
pushing the ± kilometres control past the panel's right edge instead of
ellipsing the name.
The menu that select opens now grows to its options rather than inheriting the
narrowed trigger, bounded by the panel it sits in so it never hangs over the
map: a list of identical shortened prefixes is not a list you can pick from.
A seven-hundred-kilometre day turns up seventy hits spread over the whole of
it, and the traveller wanting a break near Berlin has to read all seventy and
work out from "245 km along the route" which ones are anywhere near. That is
the wrong way round: you decide roughly where to stop, and then look at what is
there.
Picking a point narrows the list to what lies within 25, 50 or 100 km of it.
This is a subtraction rather than a second search, because the stops and the
hits are already measured along the same thinned line.
The midpoints of the legs are offered alongside the stops, and they are the
point of the feature: a break on a five-hour drive belongs in the middle of the
drive, not at either end. Asking only about stops would answer "what is near
Berlin" when the question is "what is near the halfway mark".
The chosen point is stored as which anchor it is rather than as a distance.
alongKm is measured against a line thinned with a tolerance derived from the
corridor width, so a kilometre figure kept here would quietly drift when the
width goes from 5 to 10; resolved through the stops it lands on the same place
again. It also names its day, so switching days cannot apply yesterday's break
to today's drive.
The corridor query has always ended in `out center tags`, and the projection
has always kept six keys out of the whole tag set. Everything an electric car
needs to know was in the answer and went in the bin: which plug families the
station has, how many of each, at what power, how many vehicles fit, and whether
it charges. Reading them costs no extra request, no key and no second query.
The filters treat "not stated" as its own answer, which is the only honest way
to build them: OSM states a socket type on roughly a third of charging stations
and a power on fewer still, so a power filter keeps every station that says
nothing rather than reading silence as too slow. The plug dropdown offers only
the families this search actually turned up, because CHAdeMO in a list for a
corridor with three Type 2 posts on it is a filter that can only return nothing.
Socket names stay as they are printed on the plug. "Type 2" is not a string
anyone translates, and the lowercase tag key is not what is written on the
charger.
The tool description follows, as parity requires, including the coverage caveat
— a model that reads a missing power as "no fast charging here" would be wrong
about most of the map.
An ordinary place could not become a fuel stop without going through the full
form, and the number in the rail is exactly what changes when it does: a service
stop has none. So the number is the control. Click the 3, pick the pump, and the
3 becomes an orange disc while everything below renumbers itself. The disc of a
service stop opens the same picker the other way, with a row to make it an
ordinary destination again.
Six coloured discs rather than six lines of text: at that size the colour and
the shape are the label, and anyone who has seen one petrol stop on the map
knows the orange pump before reading anything.
Centred on the column rather than on the number, which sits hard against the
left edge and would hang the panel half over the map. The click stops at the
disc, so picking a kind never also selects the place underneath.
Also in this change, from the same round: the corridor search offers hotels, a
hit somebody could sleep at opens the popup in an overnight mode with a check-out
day instead of a dwell, and booking one writes the place, its day and the row in
day_accommodations together. A hotel keeps its number, because sleeping there
ends the day rather than interrupting the drive, which is why the category list
and the stop-kind list are now two lists instead of one.
Reported off a real day: Berlin said 245 km since the last fill-up and Sanssouci
said 121, as if the tank had been filled in between. It had not. The budget was
being zeroed at every warning, so the first figure was right and every one after
it was the length of one leg rather than the distance on the tank.
A warning is not a fill-up. Only a fuel or charging stop resets it now, which is
what makes putting one in the answer rather than a formality: the same day reads
125, 370, 519 without one, and collapses to a single mark with one.
Nothing is flagged on a fuel stop either. Reaching a petrol station on an empty
tank is the plan working, and a warning there would sit on the one stop that
answers it.
Also: the stop number sits level with the middle of its row rather than at the
top, the way the corridor list already places its badges. A number pinned to the
first line drifts off as soon as a stop carries a stay and a walk under its name.
The three figures under the button were one run-on string joined by dots, which
says nothing about which number is which. Each takes the badge shell the rail
already uses, with its own icon: a clock, a calendar, a pump. The icon tile in
front of the title goes, because it was eating exactly the width the three
badges need to sit on one line.
Distances there go through formatDistance, so an imperial traveller reads miles
on the button and types miles in the dialog while storage stays kilometres.
EmptyState gains a compact variant. A sidebar state sits under its own controls,
where a line at page size competes with them; at the caption tier it reads as
what it is, a note about why the list below is blank.