Commit Graph

969 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 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 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 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 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 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
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 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
Luffy 5ac6ce40be fix(i18n): add translations for new notification event labels 2026-09-08 16:19:31 +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 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
Maurice f1120be3c1 test(roadtrip): pin the via contracts
The schemas had no spec of their own. The two things worth pinning are the
ones the feature rests on: the anchor is a position and never an id, so a
fraction or a negative is not a leg of anything; and the batch route's
track has three states, because absent and null mean different things to a
day that already follows a line.
2026-09-07 18:36:54 +02:00