Commit Graph

69 Commits

Author SHA1 Message Date
Maurice 462a208fdf fix(planner): drop the hotel bookend leg when the hotel is the edge stop
The day's accommodation is drawn as a leg to the first and from the last
waypoint. Now that a booked night puts its hotel on the check-in day as a stop
of its own, that last waypoint IS the hotel on most days, and the leg drawn to
it is a round trip of zero kilometres: in the map line, in the sidebar's leg
list and in every exported directions link, which carried the hotel twice in a
row. The defect already existed for a night booked in the road trip; the change
above would have made it the normal case.

Both bookend rules now decline a leg whose edge stop sits at the hotel's own
coordinates, which covers the map, the sidebar, the phone route and both export
links from one place. Compared by coordinates rather than ids, because the
bookend comes off the stay row and the waypoint off the assignment.
2026-09-13 01:13:41 +02:00
Maurice 84c277993c feat(maps): show where an Amap hit came from, and keep its id
An Amap row now carries the same source mark the TREK index, OpenStreetMap
and Google rows get, named per locale because 高德地图 is the name it goes
by where it matters. The "search is on OpenStreetMap" notices and the
key hint in the details column are gone with the keys dev had already
removed, and the client-side provider mirror with them.

amap_poi_id travels with a picked result into the form, the details
column and the shadow log, so a saved place keeps the id it was found
by. A pasted Amap link goes to the resolver like a Google one.
2026-09-12 18:03:10 +02:00
A1phaN 57eac32f9f feat(map): amap as map api provider and display template 2026-09-12 18:02:57 +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 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 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
Maurice 11a0780a5b fix(planner): give narrow screens one panel at a time, and unclip the public language picker
Three reports from the same week, all "the layout is in my way".

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

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

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

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

Closes #2247
Closes #2248
Closes #2249
2026-09-05 13:22:56 +02:00
Maurice 07e4a558e6 fix(bookings): the phone keeps a booking's stop and shows the files it already has (#2216, #2217)
Editing a booking on the phone erased the stop it was linked to. The sheet
seeded its assignment from the create-for-a-stop flag, which is null on an
edit, and saved that straight back over a link the user had made on the
desktop; the server writes the field whenever the key is present. It now
seeds from the booking being edited, and the picker the desktop dialog has
always had sits in the sheet too, so a stop can be seen, changed and cleared
from a phone. The option list moved to its own module rather than being
copied.

Two more of the same shape, found on the way. The endpoint set was sent as an
empty list on every save, and the server swaps the whole set when that key is
present, so editing a transit booking dropped its stations; neither form
edits endpoints, so an edit no longer sends the key at all. And the sheet's
Files block listed only what was picked in this session, so a file uploaded
from the desktop was invisible on the phone. It lists what is attached now,
resolved the way the desktop resolves it.

A place also shows the files of the bookings that sit on it, on the phone and
on the desktop: a confirmation lives on the booking, and the place card was
the one screen that could not reach it.
2026-09-03 15:51:13 +02:00
Maurice c5d10dfd6f fix(client): settle the two findings Sonar raised on the audit branch
The farthest-stop reduce gets its explicit seed (the guard above already refuses an empty list, the seed makes that local), and the toast pill moves into its own component so the host stays below the complexity ceiling.
2026-09-02 21:06:51 +02:00
Maurice ed3216f65d fix(planner): an untimed hotel bookend only closes a local day-trip loop (#2157)
Without stay times the bookend leg drew unless a carrier or a 2000 km edge stop disproved it, which is exactly the reported car trip: home five hundred kilometres away, routed back to the hotel after check-out. The disproof threshold is a day-trip distance now, and the route optimizer anchors through the same rule on desktop and mobile instead of unconditionally, so drawing and optimizing agree.
2026-09-02 21:06:51 +02:00
Maurice faa27e3608 fix(planner): a place shows every booking linked to it, not just the first (#2201)
Four render sites resolved the day assignment's booking with find(), while the booking dialog happily links several to the same stop. A shared selector in dayMerge returns them all, time-sorted and stable, and the desktop plan tab, the place inspector, the mobile timeline and the mobile place sheet render the full list; the single-booking case is unchanged pixel for pixel.
2026-09-02 21:06:42 +02:00
Maurice c4fbf03928 fix(planner): guard the post-drag click swallow against a torn-down document 2026-09-01 17:18:10 +02:00
Maurice d1aa573167 fix(budget): seed edit fields with two decimals and allow signed amounts for refunds (#2175, #2176) 2026-09-01 17:18:10 +02:00
Maurice 49b0ce28d5 fix(planner): no hotel bookend legs against a same-day carrier without stay times (#2157) 2026-09-01 17:18:10 +02:00
Maurice 9162f5d7ac fix(maps): stop the day route drawing drives to airports you flew from
A flight leaving overnight contributes only its departure airport on the day
it leaves, and the evening hotel bookend was attaching to whatever point sat
at the day's edge. So the map drew a straight line from the airport you took
off from to the hotel you were checking into.

The same blind spot on the run builder produced the long rays: a day's order
is a bare wall clock, so an inbound long-haul whose departure clock reads late
sorts after the day's local stops, and its far departure airport got appended
to that run. OSRM answers such a pair with NoRoute and the fallback straightens
the whole chunk, taking the genuine local drive with it.

Both now ask whether the pair could be driven at all. A flight, train, ferry,
cruise or coach carries you out of the day's geography, so its departure point
has no drive back to tonight's hotel and its arrival point had no drive from
this morning's. A hire car is the opposite and stays out of that set: you keep
driving it, so a multi-day rental's pickup and drop-off points keep their hotel
legs. Alongside it, any leg touching a booking endpoint has to be within
MAX_DRIVE_KM, which catches the mis-sorted edges the role alone cannot see.

The sidebar gets both guards so its hotel connectors stay in step with the
drawn route; the mobile map and timeline read the same hook.
2026-08-29 12:23:30 +02:00
Maurice 4854377603 fix(oidc): stop the login page signing a user back in against their will
Two reports, one line: the automatic bounce to the provider in useLogin,
which fires whenever password login is off. Both reporters are on an
OIDC-only install, which is what arms it.

#2126, the redirect loop. The re-entry guard sat inside the oidc_code
branch, but the exchange strips oidc_code from the URL before it
navigates away. Any re-run of the effect during that window - and t is a
dependency, so a language change is enough, which every non-English
install does on the login page - read an empty search, missed the guard,
fell through to the config probe and bounced to the provider. The
provider answered silently, landed back on /login?oidc_code=, and round
it went. Each pass mints a valid session, which is why closing the tab
and reopening lands the reporter logged in. The guard is now the first
statement of the effect, so no re-run can reach the redirect.

#2123, logout that logs you back in. The brake for the bounce was
noRedirect in router location state, and it does not survive the trip:
logout flips isAuthenticated on a sync lane while the router defers its
location update, so ProtectedRoute renders once on the old route and
emits <Navigate to=/login replace> with no state, overwriting the entry
the logout handler had just pushed. Location state cannot cross a full
document load either, and api/client.ts performs one on any 401. The
fact now lives in sessionStorage, set in authStore.logout() so all seven
sign-out call sites are covered, and cleared on any successful auth and
on the manual SSO links. Per tab, so a fresh tab still single-signs-on -
that being the point of single sign-on.

Both tests were confirmed red without their fix and green with it. The
first needed rewriting to do that: the obvious version held the page
mounted past the window the race lives in and passed either way.

Not included: RP-initiated logout at the provider. TREK clears its own
session correctly, and ending the provider's is a separate decision -
it needs an end_session_endpoint the provider may not advertise, a
post_logout_redirect_uri registered on their side, and an answer to
whether signing out of TREK should sign you out everywhere.
2026-08-29 01:30:26 +02:00
Maurice b401da2264 fix(maps): default the Leaflet basemap to OpenFreeMap
CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile
on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves
a self-hosted instance needing an email exchange with CARTO before its maps
look right, and every default in the client still pointed at CARTO. Default
to OpenFreeMap Positron instead: no key, no account, no request limit,
commercial use allowed, attribution required.

OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre
style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL
canvas into Leaflet's own tile pane, which leaves everything above it alone -
markers, GeoJSON, clusters, plugin layers, the custom panes, and the click
handling, since Leaflet's CSS already puts pointer-events:none on that layer.
maplibre-gl still arrives only through a dynamic import, so no chunk that
draws a map grows by a megabyte.

Switched: the trip planner, the MapView fallback every preview goes through,
the journey map, collections, the atlas, the shared trip page and the offline
tile prefetch. OpenFreeMap Positron and Bright join the tile presets in
settings, the admin defaults and both mobile screens.

A CARTO template that is already saved keeps working when a key backs it -
that stays a supported choice. Without a key it is not drawn at all:
resolveTileUrl falls back to the default rather than to a watermark, so an
instance that upgrades with one in its settings sees the new basemap
immediately and needs no migration. The stored value is left as it is, so the
Map settings tab still shows what was chosen and the warning underneath it
explains why - one sentence appended, in all 23 locales.

OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles
is wrong and a licence problem. attributionForTile() derives it from the URL
rather than from a flag at each call site, and the shared trip map, which
carried no attribution at all, now gets one.

Offline maps keep working and get cheaper. A vector style is four things
instead of one, so sync/glPrefetcher.ts walks the style document, its sprite,
the Latin glyph ranges of each font stack it uses, the TileJSON - the planet
path is versioned, so guessing it answers 200 with an empty body - and then
the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is
2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177
requests and 32.1 MB and stays sharp above that, because MapLibre scales the
z14 tile rather than asking for another. The Workbox rule for
tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache,
so browsing tops a downloaded region up instead of filling a second copy
beside it - a versioned tile path can never be stale. Clearing offline maps
clears it too.

The atlas loses its second raster layer. It existed to warm the HTTP cache of
neighbouring zoom levels, which a vector basemap does not need; a second one
would only cost a second WebGL context. OpenFreeMap has no label-free style,
so its labels are switched off on the live map instead - they are all symbol
layers, while borders and coastlines are line and fill - re-armed on
style.load so a theme switch keeps them off.

Theme changes call setStyle in place rather than rebuilding the layer: a swap
per toggle would leak a WebGL context, and browsers cap those in the low
teens.
2026-08-29 00:51:43 +02:00
Maurice 4d6820a4d4 @
fix(planner): route a layover flight per leg, and count linked places as planned

Two v4.0.0 reports, both a surface answering a question with less than it had.

The map built its own waypoint order. It read the BOOKING position and never
expanded metadata.legs, while a multi-leg booking stores its position per leg —
so a Frankfurt to Vancouver flight via Reykjavik was dropped from the list and
the airport before it was joined to the airport after it in one driving run
across the Atlantic. It now takes the order from getMergedItems, the same one
the day plan renders, which brings the span filter and the leg expansion with
it. getMergedItems in turn had never read day_plan_position, the one slot the
map did read, which is how the two could disagree at all.

getTransportRouteEndpoints handed back the booking outermost airports for every
leg. A stop between two legs was therefore routed from the arrival airport back
to the departure one — a drive from Vancouver to Frankfurt with a cafe in
Reykjavik on the way. A leg now spans its own pair, when the endpoints line up
one per stop with the legs; anything ambiguous keeps the role lookup.

Being planned meant one thing: a day_assignments row. A hotel is linked through
its stay and a venue through its booking, and neither is ever dragged onto a
day, so both sat under Unplanned while the day header printed their names. The
three places that asked the question — desktop sidebar, map markers, phone
browser — now ask one helper that also counts a stay and a day-anchored
booking. A booking that names no day still counts as unplanned, because nothing
says when it happens, and the day plan renders nothing for it either.
@
2026-08-27 19:21:06 +02:00
Maurice 1f66ef1d32 fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile
fetched without a key since 26.08.2026, and both CartoDB presets plus the
built-in fallback are CARTO. The key is free and needs no account, so add
it as a setting rather than dropping the provider.

carto_api_key sits next to mapbox_access_token: per user in Settings/Map,
instance-wide under Admin/User Defaults, encrypted at rest, injected from
CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the
map provider, since a raster key says nothing about the renderer.

The key is appended as ?key= at render time instead of being stored in the
template, so a saved URL stays portable and survives a rotation. That is
also what reaches the four maps that pinned CARTO and ignored the user's
tile setting: atlas, collections, the public trip share and the public
journey. The two share pages read it from their token-gated payload, the
same way baseCurrency already travels.

Also lifts the release notice above page chrome. Its overlay sat at
z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither
on top nor modal. Adds a six-step layer scale to index.css and moves the
three notice shells onto it.

Closes #2054
Closes #2052
2026-08-27 12:06:55 +02:00
jubnl 5b9aaab847 fix(client): mint upload idempotency keys via randomId()
uploadQueue called crypto.randomUUID() bare, which is undefined on
non-secure origins (plain-http localhost and LAN installs) and threw
instead of uploading. Route it through randomId() like the other two
idempotency-key producers so it falls back to getRandomValues, and
update randomId's header comment to name all three producers.
2026-08-25 23:16:18 +02:00
Maurice 1ad2e92142 fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.

**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.

`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.

**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.

Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.

sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
2026-08-25 19:26:38 +02:00
Maurice 52d1a0720f fix: say out loud what every sort was already doing
Sonar's S2871 flags a bare .sort(), which orders by UTF-16 code unit rather than
by value. All 30 remaining sites turned out to sort ASCII strings — ISO dates,
currency and country codes, setting keys, route names, OAuth scopes, zip entry
names — where that order is already the intended one. Each says so explicitly
now, with a comparator that reproduces it exactly rather than a localeCompare
that would quietly reorder things.

That mattered more than it sounds. Several of these feed something other than a
display: useAtlas joins the sorted codes into the signature that decides whether
to rebuild the region layer, daysSlice re-pins sorted dates positionally onto
reordered days, and three timeline views pick a day's colour by indexOf into the
sorted array. Switching any of them to a locale collator would have been a silent
regression.

Two smaller ones came with it. The .map(placeBtn) calls in FileManagerAssignModal
pass the index as a second argument, which placeBtn declares and uses for its
React key — correct already, now written out as .map((p, i) => placeBtn(p, i)).

And budgetSlice's optimistic paid-toggle stored the raw boolean behind
`paid as unknown as number`, while the schema declares z.number() and both the
REST response and the WebSocket event carry 0/1. So the optimistic state and the
confirmed state disagreed on type until a refresh. It normalises now, and the two
tests that asserted `true` assert the contract.

Four `| 0` truncations are listed in sonar-project.properties instead of changed.
Two are a djb2 string hash where the 32-bit wrap is the algorithm. The other two
look like plain truncation of a row id, but `| 0` is also coercing undefined to
0 there — the settlement flows the API returns carry no user_id, so that is the
normal path, and Math.trunc returns NaN and crashes the lookup.
2026-08-25 19:26:38 +02:00
Maurice 2c2d008dc2 fix: clear every open Sonar security issue, and half the reliability ones (#2037)
* fix(security): mint idempotency keys properly, and resolve the extractor by path

Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.

The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.

The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.

* fix: four tests and sorts that were not doing what they claimed

Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.

budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.

admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.

The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.

Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).

* refactor: give every button a type, and prefer the Number statics

Two mechanical sweeps, no behaviour change intended in either.

A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".

The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
2026-08-25 15:22:38 +02:00
jubnl efcdbd94fb fix(client): compute "today" from the local calendar, not UTC 2026-08-25 00:44:42 +02:00
Maurice 4bac447ac8 fix(planner): draw only the selected day's automated transports
Closes #2019

The route toggle belongs to one day; the reservation list it filtered is the
whole trip. So turning on a day's routing let every transit reservation in the
trip draw its geometry at once, including the ones belonging to other days —
on a day that has no transport of its own, which is what the reporter saw.

Only automated transports rode it, and that is the distinction they drew: a
manually entered train or flight is type 'train'/'flight' and reaches the map
only through its own per-booking toggle. Only the transit search stores
type 'transit'.

Gate the transit branch on the day, and gate only that one — the per-booking
toggle stays trip-wide, because switching a booking's route on is a deliberate
act about that booking. The predicate is the day-order range, not the departure
day: an overnight journey carries end_day_id and has to stay on both of its
days, the same rule the sidebar timeline already applies. Day order rather than
day id, since ids stop being monotonic once days are reordered.

The day context is optional throughout, so a map without one — collections —
keeps behaving exactly as it did. Not a 4.0.0 regression: it reproduces on
3.4.1 too.
2026-08-23 23:43:14 +02:00
Maurice 44ddfa510e fix(client): only give a scheme to something that looks like a host
safeExternalHref prefixed https:// to anything without a scheme, which turns
a note that reads "call the hotel" into a link to a host that does not exist.
A dot and no whitespace is what separates www.hotel.com from free text.
2026-08-23 11:59:12 +02:00
Maurice 7c6a17c020 fix(client): make the new rollbacks roll back only what they wrote
The rollbacks this branch added were snapshot-shaped: capture the whole
collection before the write, restore it on any failure. That is wrong in two
ways at once. It undoes work the server already accepted when a later step is
the one that fails, and it throws away whatever a collaborator's socket event
applied while the requests were in flight.

The day-plan reorder is the clearest case: five awaits, and a failing day-note
write reverted transport positions the server had already stored. It now
records only the fields it is about to overwrite, per booking, and drops each
one as its request lands - so a note failure reaches the catch with nothing to
undo, and a booking that arrived mid-write survives.

The notification toggle had the same shape underneath: the rollback was made
surgical but the request still shipped the whole matrix built from the
pre-toggle render, so two overlapping toggles overwrote each other anyway. It
sends the one cell that changed. The test pinned the disagreement as correct;
it asserts the payload now.

Photo reorder awaits allSettled rather than all, so the UI is not snapped back
while the remaining writes are still landing - a partial run really does leave
the server holding part of the new order, and only a total refusal justifies
restoring the old one. Both the desktop editor and the mobile sheet, which was
still swallowing every rejection.

Also: the geolocation start guard is a run generation now, because a start
issued after a stop re-sets a plain flag while the first run is still parked in
the iOS permission prompt; a category rename reports once for the batch and
rethrows, so the settings dialog stops closing as if it had worked; and a 404'd
import job stops telling the user their file was invalid when it had simply
expired.

The clipboard fallback for plain-HTTP installs was pasted into three files by
this branch. It is one helper now, and the two copy buttons in the members
modal that still called navigator.clipboard unguarded use it too. Same for the
fourth local-date helper written next to the three that already import the
existing one.
2026-08-23 11:59:12 +02:00
Maurice 818021908f fix(client): correct the second pass over this branch's own client fixes
Four of the fixes already on this branch were wrong, and an adversarial read
of the diff is what turned them up.

The shared merge helper claimed to implement the server's
COALESCE(assignment_time, place_time) and did not: it pinned the embedded
times to whatever the assignment already showed, ignoring the override
column entirely. Sharing that with the WS applier traded one bug for
another - a collaborator moving a place from 08:00 to 10:00 stopped reaching
the day card for every assignment WITHOUT an override, which is most of them.
It merges the way the server projects now, and the test that was supposed to
pin this seeded a fixture the server could never produce (embedded times that
differ from the pool row with no override to explain the difference).

The per-iteration auth gate went into every loop in tripSyncManager except
the one that matters most: the mutation-queue replay, which writes server
responses straight into Dexie. After a logout that is the shared anonymous
database. The fire-and-forget tags/categories writes two lines below a gate
added in the same commit were ungated for the same reason - they resolve
later, and the check has to happen then.

safeHttpUrl is backed by placeWebsiteSchema: http(s) only, capped at 500
characters. A booking url is deliberately free-form - shared's own contract
says so in as many words, because people paste bare hosts and long provider
deep links - so guarding it with the stricter schema turned a link somebody
saved last year into dead text. safeExternalHref refuses the schemes that
execute in this origin and gives a scheme-less value https://, which is what
an address bar does with the same input.

And the memories timeout was 20s while the server's own budget for a Synology
call is 30s, doubled again when the session has expired and it re-authenticates:
a slow NAS that answered before this branch would have started reading as
disconnected. Those calls had no client timeout at all before; they have none
again, and the ones that need to give up early carry an AbortSignal.

The notes thumbnail also loses its NUL byte cache separator - it made git treat
the whole file as binary, so a security fix landed in it unreviewable - and
gains the same cache cap its chat sibling got.
2026-08-23 11:59:12 +02:00
Maurice 47a4276e84 fix(client): make the offline core survive a logout and stop clobbering itself
logout() unregistered every sync trigger, and the only place that registers
them is App's mount effect - which never runs again, because logout navigates
through the router rather than reloading. Log out, log back in, go offline,
edit something: it queues and nothing ever flushes it. No online listener, no
visibilitychange, no 30s heartbeat, no WS reconnect hook. Register on auth
success instead; registerSyncTriggers is already idempotent.

The background sync loops only checked the auth gate on entry. Logout swaps
the Dexie handle to the shared anonymous database, so a trip loop or a blob
download still in flight afterwards wrote the logged-out account's itinerary
and documents into it - the database that survives logout and greets the next
person on a shared device. Check the gate per iteration.

And the remote place:updated applier replaced the whole embedded place, while
the local reducer right next to it preserves the assignment's own times. Same
event, two answers, and the remote one wipes a scheduled 09:00-10:30 off the
itinerary when someone else renames the place. One merge helper now, used by
both, so they cannot drift again. The test that should have caught it seeded
null times and asserted only the name.

Also: moving a day note deleted before it created, and restored the note only
locally when the create failed; t() interpolation was corrupted by a '$' in a
parameter value; a 429 upload was treated as permanently failed; and the
geolocation watch was started inside a state updater.
2026-08-23 11:59:12 +02:00
jubnl 305b113d5f Feat/storage driver (#1949)
* feat(storage): storage key validation, driver contract suite, LocalDriver

* feat(storage): MirrorDriver composite backend

* feat(server): add TREK_UPLOADS_DIR env var for the uploads root

* feat(storage): settings-backed registry, StorageService facade, module wiring

* test(storage): pin src/nest/storage coverage thresholds

* feat(storage): multer upload options factory

* refactor(files): spool trip-file uploads through storage

* refactor(collab): spool note attachments through storage; normalize files/ prefix

* refactor(journey): spool journey media uploads through storage

* refactor(trips): spool trip cover upload through storage

* refactor(places): spool place-image and collection uploads through storage

* refactor(auth): spool avatar upload through storage

* fix(server): consolidate allowed_file_types onto AllowedFileTypesService

* fix(storage): default defParamCharset to utf8 in the upload factory

* test(db): re-point the trailing-migration guard at the trip_files prefix strip

* fix(storage): age-gate the boot spool sweep

* test(files): key FILE-P02 residue checks on upload size

* test(storage): raise src/nest/storage coverage pin

* refactor(platform): serve /uploads routes through storage

* refactor(files): serve trip-file downloads through storage

* refactor(memories): serve photo bytes through storage

* refactor(maps): stream place-photo bytes through storage

* refactor(share): stream shared place-photo bytes through storage

* refactor(journey): serve public journey assets through storage

* fix(share): answer place-photo cache miss with 204 like the maps proxy

* test(server): close serving-swap gaps surfaced by the full tier

* test(storage): raise src/nest/storage branches pin

* refactor(place-photos): route place-photo cache through storage

* refactor(places): reclaim place images and collection covers through storage

* refactor(trips): trip covers through storage

* refactor(auth): avatar reclaim through storage

* refactor(collab): note-file deletion through storage

* refactor(files): trash deletion through storage

* refactor(memories): trek photo cache internals through storage

* refactor(memories): thumbnail service category-addressed

* refactor(memories): immich upload reads through storage; drop uploads-root

* refactor(journey): photo deletion through storage

* fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep

* feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim

* test(server): close slice-4 gaps surfaced by the full tier

* test(coverage): raise slice-4 domain pins

* refactor(backup): list, existence and delete through storage

* refactor(backup): download through storage sendToResponse

* refactor(backup): create backup through per-category storage enumeration

* refactor(backup): restore and upload-restore through storage

* refactor(backup): auto-backup retention through storage

* refactor(server): boot dir-creation to driver init

* test(server): close slice-5 gaps surfaced by the coverage tier

* test(coverage): raise slice-5 backup pin

* test(server): resolve async backupFileExists mocks for the tests typecheck

* test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract

* feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule

* feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines

* refactor(server): type the aws-lite S3 client via s3-types reference

* feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination

* feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline

* fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup

* feat(server): env-declared s3-main storage backend; settings-declared s3 rejected

* test(server): env-gated S3 driver contract suite against MinIO

* fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect

* fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost

* ci(server): MinIO-backed S3 driver contract job

* ci(server): align s3-contract shim comment and MinIO bind with references

* docs: S3 storage backend configuration and off-box backup mirroring

* docs: restore README formatting, keep S3 additions only

* ci(server): make live-MinIO contract runs manual-only (AIStor licensing)

MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header.

* chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case

* expose admin ui

* feat(shared): storage backend type registry, wire schemas, category relocation

* feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview()

* feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path

* feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact)

* chore(server): drop storage-secrets import that only Task 8's probe helper will use

* feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed

* feat(server): registry snapshot() — effective storage config with provenance

* feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits

* feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually

* test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes

* test(server): give e2e harness DBs the app_settings table the storage registry reads at boot

* test(coverage): raise pins after the storage admin-config slice

* fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes

* test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401

* feat(i18n): storage admin domain + tab label in every locale

* feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend

* feat(client): storage admin draft-document helpers + relative-time util

* feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner

* feat(client): admin storage panel — backends list, category map, health strip, one-PUT save

* feat(client): storage tab on the admin page

* feat(client): storage admin panel and section on the phone shell

* fix(client): block renaming a storage backend onto an existing name

* feat(server): allow mirrors on any storage category — lift the backups-only rule

* feat(i18n): replicas-on-primary mirror strings in every locale

* feat(client): mirror fold/synthesize model helpers for replicas-on-primary

* feat(client): BackendForm mirror-targets composer; raw mirror type hidden

* feat(client): desktop storage panel folds mirrors into their primaries

* feat(client): phone storage panel folds mirrors into their primaries

* fix(client): strip removed backends from remaining mirror replicas — no phantom refs

* feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam

* feat(i18n): descriptive storage category names and descriptions in every locale

* feat(client): descriptive category names, descriptions and id badges in the storage panel

* docs(storage): fix stale nine-category comments after the eight-category shrink

* docs: storage configuration via the admin UI and seed file — README + compose

* docs(wiki): Admin Storage page and storage cross-links

* docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier

* refactor(server): drop the storage env-var tombstones — the variables never shipped

* docs: storage config is simply new in v4 — drop the removed-variables story

* feat(shared): backfill-status and usage wire schemas on the storage admin state

* feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up

* feat(server): one-at-a-time storage backfill jobs + the storage events bridge

* feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt

* feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration

* feat(i18n): replica-failure notification text in every locale

* feat(server): storage backfill and stats-refresh admin endpoints

* feat(client): adminApi backfill and stats-refresh endpoints

* feat(i18n): storage sync and usage strings in every locale

* feat(client): desktop storage panel — sync now, usage display, backfill polling

* feat(client): phone storage panel — sync now, usage display, backfill polling

* fix(client): per-category usage line on the phone storage panel

* docs: sync now, usage stats and replica-failure notifications

* test(server): admin scope now includes replica_failure, not just version_available

Commit 6db135da added replica_failure to ADMIN_SCOPED_EVENTS but left
NPREF-005b asserting the admin scope contained only version_available.
Update the expectation to match.

* test(server): cover storage backfill/stats/jobs branches to hold the coverage pin

* fix(client): guard storage admin polling against stale responses racing a save

A poll GET issued before a save's PUT resolves could land after the save
applied its fresher response, overwriting it with the pre-save world and
re-deriving the draft from that stale snapshot. useStorageAdmin now tracks
a state-application sequence number and drops any refreshState() response
whose sequence has moved on, or that resolves while a save is in flight.
Also guard the fire-and-forget refreshState() calls in startBackfill/
cancelBackfill against unhandled rejections, matching the poll's style.

* fix(server): report 404 before 409 when a backfill start names an unknown mirror

startBackfill checked the global busy flag before validating that :name is
a mirror routed by some category, so POST .../backends/garbage/backfill
answered 409 instead of 404 whenever a sync was already running. Resolve
and validate the target first (BackfillTargetError -> 404), then check
busy (BackfillBusyError -> 409).

* feat(client): two-column category grid on the storage panel

* feat(server): plugin files.getContent reads through the storage layer

* feat(server): plugin files.create writes through the storage layer

* refactor(server): drop FilesService.resolveFilePath, last direct-fs seam in the plugin surface

* fix(server): map StorageInvalidKeyError to the plugin file accessibility envelope

* feat(storage): allow credentialed backends without an explicit ENCRYPTION_KEY

Secrets are still always encrypted at rest; without an explicit key the
implicit data-directory key is used. Removes the plaintext-secret refusal
from the admin PUT and the seed import, the encryptionReady GET field, the
save-blocking banner in both admin shells, and the now-consumerless
security.encryptionKeySet env derivation. Docs now recommend (not require)
an explicit key, since the implicit key rides inside backup archives.

* test(client): pin the stale-poll release to the pre-save GET in FE-ADMIN-STOR-024

The stub deferred EVERY poll GET and rebound the release handle to the
newest one, so a tick landing after the save was released with the
pre-save payload — a response no real backend can produce, which the seq
guard rightly applied. Only the first (genuinely stale) GET is held now;
later GETs answer with the world the mock server currently holds. Also
drops the encryptionReady fixture field removed from the schema in the
previous commit.

* fix(storage): backfill puts carry content-type, stat failures report op stat, progress clamps

* feat(storage): category migration job — copy, flip, delta sweep, reclaimable tally

* feat(storage): migration start/cancel endpoints and client API

* test(client): storage admin fixtures carry the new migrations field

* fix(storage): migration POST answers 200 and validates its body via the shared schema

* fix(storage): cancel running storage jobs when a config save removes their backend

* fix(notifications): in-app replica-failure row carries the suppressed count

* feat(client): migration prompt, queue, and progress on the storage panel

* fix(client): migration queue holds a synchronous single-flight lock

* feat(client): migration prompt and progress on the mobile storage panel

* fix(server): register category-migration routes in the managed-route allow list

StorageAdminController.migrationStart/migrationCancel were added under the
controller-level @ManagedForbidden() decorator but never added to
MANAGED_ROUTE_ALLOW_LIST, so validateManagedRoutes() threw "Managed surface
changed" on every buildApp() call, failing 46 test files (any test booting
the real app).

* test(server): cover category-migration copy/sweep branches to hold the storage coverage pin

Adds MIG-005 (a target object already matching size/key is skipped in the
copy phase itself, not just the delta sweep) and MIG-006 (a copy failure
during the delta sweep is counted without undoing the flip that already
happened). src/nest/storage/**'s branch coverage was 83.91%, just under the
84% pin.

* docs(storage): category migration — move existing objects on reassign

* fix(storage): close the pre-flip cancel window and cancel re-routed migrations

A cancel landing between the last copy iteration and the category flip was
silently ignored, and a config save that re-routed the migrating category to
a third, still-defined backend never stopped the in-flight migration. Also
guards assignCategory against writing an unknown backend name, and fixes a
missing `suppressed` notification param being treated as truthy suppression.

* fix(client): migrations keep mirrors, queue waits on backfills, sweep failures surface

Reassigning a category onto a mirrored primary now posts the migration to the
mirror's wire name instead of the bare primary, so replication survives the
move. The queued-migration effect also waits out a running backfill (the
server's one-job rule spans both), and a done migration with sweep failures
now surfaces a doneFailures line in both shells, translated into every locale.

* ci(server): run the S3 contract suite against MinIO

Resurrects the s3-contract job removed in 6fbff835, adapted for AIStor:
writes the MINIO_LICENSE_KEY secret to a license file, boots the licensed
container, and runs the contract suite hermetically (no --coverage) so the
storage coverage pin keeps holding from unit tests alone. Gated to same-repo
PRs since fork PRs have no access to the license secret.

* fix(server): backup archives uploads from a remote primary

createBackup() enumerated uploads through withLocalFile, which downloads a
remote object to a temp file, hands it to the callback, then deletes it
before archiver ever reads it — archiver reads entries lazily during
finalize(), so a remote (S3) primary silently backed up zero upload bytes.

Add StorageService.getLocalPathOrNull(category, name): a narrow locality
probe that returns a real path only when one exists on disk right now (the
fs-existence check is the fail-safe half of the contract — a driver-reported
path that vanished between list() and here falls back too, not just a
path-less remote driver). The enumeration loop uses it directly when
available (zero-copy, unchanged for a local primary) and otherwise streams
the object into a per-backup staging dir under the backups spool, cleaned up
in the existing finally alongside zipSpool/dbSnap.

Also wire archive.on('warning', reject): archiver treats a missing/unreadable
entry as a warning, not an error, and skips it by default — which would
silently drop the entry from an otherwise "successful" backup.

* fix(server): client aborts stop 500ing and leaking streams

storage.service.ts sendToResponse: the local res.sendFile branch now treats
ECONNABORTED/ECONNRESET/ERR_STREAM_PREMATURE_CLOSE/syscall=write as success,
mirroring res.sendFile's own default callback (the contract
storageStaticHandler already implements caller-side). The remote branch
replaces the hand-rolled pipe+Promise with pipeline(stream, res), which
destroys the source stream on any failure — fixing the leaked S3 keep-alive
socket on a client abort — while swallowing the same client-abort error set;
a real source error still rejects so callers keep their miss contracts.

This centralizes the fix for every caller that streams through
sendToResponse: files-download, journey-public, photo-resolver,
trek-photo-cache, backup downloads, and /uploads/photos.

trek-exception.filter.ts now no-ops (destroying the socket) when headers are
already sent, so no filter path ever writes onto a response in flight.

maps.controller.ts and share.controller.ts's place-photo bytes proxies
switch their direct getStream+pipe to pipeline() with the same abort-tolerant
catch, keeping the existing 204-empty fallback for pre-header failures.

* fix(storage): mirror read fallback consults replicas on primary miss

MirrorDriver.getStream/stat now fall back to replicas on a primary miss
(StorageNotFoundError / null stat), not just an infra error — binding
decision: a read succeeds if ANY member holds the object. Only a
deterministic StorageInvalidKeyError skips fallback.

stat() gets a dedicated fallback loop instead of reusing fromReplicas:
a replica returning null means "keep looking," which the generic
catch-and-continue helper would have short-circuited on.

StorageService.sendToResponse/withLocalFile stop treating composite
driver.stat() as proof the object is at localPath; both now fs-check
the path itself and fall through to the stream branch when it's
missing, which is what actually reaches a MirrorDriver's replicas.

* feat(storage): remote serving parity — content-type, validators, range

Local backends get content type, ETag/Last-Modified, conditional GET, Range
and HEAD for free from res.sendFile; the proxying branch every remote
backend (and a local primary whose file vanished) falls through to had none
of it — every request re-streamed the whole object with no cache validator
and no way to seek.

sendStreamed() reproduces the contract from ONE pre-flight driver.stat():
a miss throws before any header is set (the caller's 404/204/next()
contract), and that same stat then answers 304s, 416s, HEADs and suffix
ranges without ever opening a body. The decisions live in a new pure module,
http-serving.ts:

- validatorsFor: the driver's ETag, else express/send's weak
  W/"<size hex>-<mtime hex>". mtimeMs 0 (S3 can report it) means no
  Last-Modified and no derived tag — serving uncached beats a 1970 header
  that reads as fresh forever.
- isNotModified: If-None-Match (weak compare, *, list) wins over
  If-Modified-Since, per RFC 9110 §13.2.2.
- decideRange: single range only, inclusive clamp, suffix computed from the
  pre-stat size, unsatisfiable → 416, If-Range strong-exact or full entity.
  A multi-range or malformed spec is answered with the full object rather
  than multipart/byteranges. If-Match/If-Unmodified-Since stay out of scope.

Range math uses the pre-stat size as the single source of truth, the ranged
getStream stat is never re-consulted, and the pipeline abort semantics are
unchanged.

S3Driver now surfaces the bucket's ETag on stat()/getStream() (on a ranged
GET it is still the full object's tag). aws-lite strips the tag's
surrounding quotes — verified live against AIStor RELEASE.2026-08-07 — and
an unquoted entity-tag is not a valid HTTP ETag, so the driver restores
exactly the quotes the SDK removed and nothing else.

* fix(server): key rotation covers storage.backends

The S3 storage backend's secretAccessKey is encrypted at rest inside the
app_settings row keyed storage.backends, but scripts/migrate-encryption.ts
didn't know about it — after a key rotation that secret stayed encrypted
under the old key and silently failed to decrypt on the next read.

The script now re-encrypts secretAccessKey for every s3 backend inside the
storage.backends JSON array, using a pinned copy of the secret-field list
(it deliberately can't import @trek/shared). The parity test pins that copy
against storageSecretFields('s3') so a new secret field can't be missed
silently.

* fix(server): restore reloads the storage registry

restoreFromZip closed and reopened the DB but never reloaded the
storage registry, so uploads rehydration ran against the STALE
pre-restore driver/category map instead of the config the restored
archive's own travel.db carries (audit #4, major).

StorageService gains reloadConfig(), a narrow passthrough to the
(unexported) registry's reload() — same shape as health(). backup.impl's
restoreFromZip now calls it right after reinitialize() and right before
the uploads wipe/rehydrateUploads/plugin staging, so rehydrated bytes
land where the restored config says. When reopen fails (reinitFailed),
reload and rehydration are skipped entirely — already reported as
"restart required", and there is no live DB handle for the registry to
read anyway.

* fix(server): category migration rewrites keys across prefix changes

Category migration copied objects to the destination backend using the
SOURCE key verbatim. A mode-A ('' prefix) source migrating onto a
prefixed backend (or vice versa) landed objects at keys the registry
could never resolve post-flip (audit #8).

Extracts StorageRegistryService.keyPrefixFor(category, backendName) from
build()'s inline conditional so startMigration can compute the
destination prefix before the flip. runMigration now maps every
destination key (destPrefix + key.slice(sourcePrefix.length)) in the
copy skip-check, the put, and the delta sweep; the reclaimable tally
stays source-keyed. Equal-prefix migrations are unaffected (identity
mapping).

* fix(plugins): await the now-async place/collection services in RPCs

PlacesService.update/remove and CollectionsService.deletePlace became
async when they started deleting storage objects, but their plugin RPC
handlers still called them synchronously: the null/false refusal check
compared against an always-truthy Promise, and places.update broadcast
a Promise instead of the resolved place. Both handlers now await the
service call; mapCollectionError is async-aware (accepts a sync-or-
async thunk) so the sync callers (create/update/savePlace/copyToTrip)
keep working through the same path. Verified the RPC host already
awaits handler results, so no change was needed there.

* fix(storage): backfill Sync now gains a deletion sweep

The Sync now backfill copied primary objects to replicas but never removed
replica objects the primary no longer has, leaving mirror read-fallback able
to serve stale data indefinitely after a failed replica delete. backfill()
now sweeps each replica post-copy: any replica key absent from the primary
key-set is re-checked against primary.stat (a race gate against the primary
regaining the key) before being deleted. Adds a deleted counter end-to-end
(driver, jobs service, shared schema, both admin shells, all 23 locales) and
a new ReplicaFailure 'list' op for a replica whose listing itself fails
during the sweep.

* feat(storage): optimistic concurrency for admin config saves

A stale admin PUT of storage settings could silently undo a category
migration's just-flipped assignment. Adds a version counter
(app_settings row storage.config_version) bumped inside the same
transaction by both writers — applyConfig and the migration flip
(assignCategory). applyConfig compares the submitted version first
and throws StorageConflictError, mapped to 409 ahead of the blanket
400. The client carries the draft's loaded version through every
local edit and maps a 409 to a distinct "settings changed underneath
you" message, refreshing state while preserving the dirty draft for
review.

* fix(storage): surface a failed config load + redact secretAccessKey

Registry load() now records the exact parse/validate failure as
lastLoadError() instead of silently falling back to last-good config or
built-in defaults; StorageAdminService exposes it as configError on the
admin state (storageAdminStateSchema extended additively), and
AdminStoragePanel renders it as a warning banner naming the error. Save
stays enabled — it's the recovery path — but an admin can no longer save
over a broken stored config without knowing.

Also adds 'secretaccesskey' to globalMiddleware's SENSITIVE_KEYS request-log
redaction list, so storage admin PUT bodies never leak the S3 secret into
debug logs.

* fix(client): admin storage panel state races

Migration prompt now recomputes computeMigrationCandidates(draft, state)
on render and again at confirm time, so a stale open-time snapshot never
drives stripCategories or the migration queue; adds a Cancel button to
the dialog. A failed startMigration POST explicitly clears the remaining
queue with a toast naming the dropped categories and refreshes state,
instead of leaving it invisibly stranded; a "Queued: X, Y" line renders
while the queue is non-empty. Testing a mirrored row now expands the
draft primary + replicas client-side and probes each individually,
instead of sending the mirror stub to the server (which resolved
targets from its own live snapshot, silently testing saved options over
unsaved draft edits). A completed/cancelled/errored backfill now renders
its terminal status alongside the Sync now button instead of hiding it,
so it can be re-run without a page reload.

* fix(storage): refuse shared replica backends so the sync sweep cannot cross categories

A mirror's "Sync now" sweep makes each replica MATCH the primary: it lists the
replica under every prefix the mirror's categories occupy and deletes the keys
the primary doesn't hold. The backups category's prefix is '' — the replica's
entire root — so making a category-serving backend (say uploads-local) a
replica of a backups mirror meant the first sync deleted every user upload.
Nothing forbade the config: validateConfig only checked that mirror targets
existed and weren't nested, and the admin picker offered every backend.

The sweep is correct, so the fix is at the config level:

- validateConfig refuses a backend that is both a mirror replica and a
  category target (directly, or as the primary of a mirror a category routes
  to), and refuses a backend replicating two mirrors whose swept prefixes
  overlap — '' overlaps everything. Disjoint prefixes stay legal.
- assignCategory (the migration flip, the other writer of the settings rows)
  refuses routing a category onto a backend that is currently a replica.
- The admin mirror-target picker (both shells) only offers backends that serve
  no category; already-selected targets stay listed so they can be unchecked.
  A category assigned to a replica still surfaces the server's 400 verbatim.

Existing shared-replica fixtures were rewritten onto dedicated replicas.

* fix(client): a 409 conflict save is recoverable without a page reload

After a 409 the admin storage panel was a dead end. refreshState leaves a
dirty draft's version alone on purpose (audit #7: the background poll must
never float a mid-edit draft past a migration flip it never carried), setDraft
re-attaches that same version to every later edit, and save() resubmits it —
so every retry 409s again and only a full page reload got the operator out,
while the banner told them to "review and save again".

The version block stays. Instead the conflict banner now offers the one action
that can clear it: discard the local edits and reload the saved settings —
a fresh GET applied through applyState, re-deriving the draft (and its
version) and clearing dirty/saveError. Bumping the version in refreshState,
or only in the 409 branch, was rejected: either resurrects audit #7, since a
full-document save would then revert a flip the draft never carried.

storage.saveConflict now points at that action instead of implying a plain
re-save works (all 23 locales). The escape lives in the shared hook, so the
phone panel renders it too.

* fix(client): deflake the queued-migration-waits-out-backfill panel test

FE-ADMIN-STOR-037 seeds a running backfill, so the panel polls GET from
first render — but the stub served the post-save world (files: off-box)
before the PUT had landed, which no real server would do. A poll
resolving while the confirm dialog was open made the confirm-time
candidate recompute see from === to, so nothing was queued and the
migration never POSTed.

The GET stub now stays faithful (pre-save world until the PUT lands,
usage included so candidates keep their object counts), and the test
deterministically forces the once-flaky interleaving: it waits for
polls to land while the dialog is open and asserts the candidate
survives them.

* fix(storage): surface the underlying cause in probe target errors

The admin storage Test probe reported only the driver wrapper's outer
message ("put failed for '…' on 'probe-s3'"), dropping the cause that
says WHY — connection refused vs missing bucket. Flatten the error's
cause chain into the probe result, appending an S3-style code when it
isn't already in the message.

* fix(storage): surface the underlying cause in replica-failure reports

Same defect as the probe fix, one layer over: MirrorDriver's
reportReplicaFailure captured only the wrapper's message, so
replica-failure notifications said "put failed" without the why.
Move the probe's cause-chain flattener into storage.types.ts as
describeError and use it in both call sites.

* fix(storage): strip ContentType from multipart Upload calls (MalformedXML)

Every multipart S3 put carrying a contentType — every backup zip and
photo over the 10MB threshold — failed with 400 MalformedXML. Upstream
@aws-lite/s3's Upload leaks the ContentType param into its final
CompleteMultipartUpload headers ({ ...xml, ...paramHeaders }), clobbering
application/xml, which flips @aws-lite/client's payload serializer to
JSON — so the server receives a JSON parts list where XML belongs.
Confirmed live against MinIO AIStor; any S3 refuses the same.

uploadMonitored now drops ContentType before s3.Upload. Multipart
objects are stored without a content-type, which TREK never reads back
(serving derives Content-Type from the key/caller, never object
metadata); single-request PutObject paths keep sending it.

Also closes the coverage gap that let this ship: the live contract
suite never crossed MULTIPART_THRESHOLD, so s3.Upload had never run
against a real server — it now round-trips a >10MB put carrying a
contentType.

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-22 11:33:55 +02:00
Maurice 9094f05604 fix(planner): close the accommodation loop when no times are recorded
With "optimize route from accommodation" on, the drawn route still started at
the first activity on any arrival day and stopped at the last one on any
departure day. The two bookend rules ask whether you can be proven to have been
at the hotel already — but they read a missing check-in time as proof against,
and the hotel picker leaves that field blank by default. So the loop hung open
at one end on every boundary day, and at both ends for a stay booked on a single
day.

With no time recorded there is no bar to clear, so the leg is drawn. Where a
time *is* set the existing rules are untouched: a stop before check-in still
draws nothing (#1465), and a transport arrival is still not a hotel departure
(#1321).

Closes #2009
2026-08-21 13:27:27 +02:00
Maurice 9522e4ce4a fix(budget): keep an uneven split from corrupting the expense total
163.21 across two people is 81.61 and 81.60. Both are exact to the cent — the
client splits properly — but the server discarded the total it was sent and
re-derived it by adding those two as doubles, which lands on
163.20999999999998. That is the number that went into the row.

It looked like a display quirk because everything routed through Intl came
out right; only the places that print a number as it stands showed it. The
stored value was wrong all along, which is why reopening the expense showed
it back, and why the price stamped onto a linked booking carried it too.

The domain already had this rule and followed it everywhere else — the
settlement maths converts to whole cents before it adds anything. The payer
sum was the one arithmetic still done in euros, in both the create and the
update path, so it now goes through the same rule.

The client side is what makes rows that are already stored wrong stop
looking wrong: the expense form and the mobile cost sheet seed their total
from a cent-clean value, and the three panels that print a booking's price
verbatim tidy it when it parses as a number. Free text an import wrote there
passes through untouched rather than becoming NaN.

Closes #1964
2026-08-20 17:44:21 +02:00
Maurice 68bf5316ad fix(planner): stop an empty reservation event from breaking a trip for good
reservation:created and reservation:updated come in two shapes. Normally they
carry the reservation; the accommodation cascade sends them empty, as a
"something changed, go and look" ping that the event contract allows for.

The applier read the id off both. With no reservations loaded yet the
existence check never ran its callback, so the cast slipped through and the
store became [undefined]. From there every render that walked the list threw
"Cannot read properties of undefined (reading 'endpoints')" into the route
error boundary, and since the store is rebuilt from the same events, the trip
stayed unreachable in that browser. The reporter could only get out of it by
clearing localStorage.

Both appliers now decline an event with nothing on it, and the Dexie writer
does too rather than leaning on a swallowed catch. The predicate that walks
the list is total as well: it guarded the property but not the element, and
it is handed straight to Array.filter over a list it does not own.

Declining the empty ping would have left a hotel booked by one member
invisible to the others until they reloaded, so the ping is honoured the way
day:reordered already honours its own — fetch the authoritative list.

The server keeps sending what it sends. Making the cascade carry the full
reservation would mean moving the REST, MCP and RPC paths together with
their pinned tests, and would still leave the client fragile against the
next emitter that does this.

Closes #1979
2026-08-20 17:37:54 +02:00
Maurice 0a7e460253 fix(planner): keep a parked car off the days between drop-off and pickup
A parking booking spanning the trip showed up on every day, sometimes in between
planned activities. It now appears on its first and last day only, with drop-off
and pickup wording, and contributes nothing to the days in between, not even a
badge in the day header. Applied to the day plan, the mobile timeline, the PDF
export and the shared trip view, so a shared link shows what the app shows.

Rental cars stay as they are. The report names the car's smaller tag as the thing
it does not want for parking, not as something to change, and a car's middle days
move into the day header, which is what people expect from a vehicle they are
driving around.

The distance connector between legs is keyed off the row that gets hidden, so
hiding it naively takes the "2 km" label with it, which is what #1504 was about.
There is a test that fails without that guard.

Each segment of a stopover flight or train can also carry its own booking
reference now, since airlines often issue one per flight rather than one per
booking. Legs already live as a JSON array on the reservation, so this is an
optional field per entry: no migration, no contract change, and a booking without
it renders exactly as before. Readable everywhere it can be entered, desktop and
mobile, through the same blur treatment other booking codes get, plus the PDF and
the calendar description. create_transport and update_transport preserve it too,
otherwise they would accept the field and silently drop it.

Closes #1937
Closes #1943
2026-08-17 14:48:23 +02:00
Maurice c8607ba234 fix(places): pin the image and website URL fields on the way in
image_url and website were bare strings in the contract, and the create/update
bodies are open records, so the Zod pipe never looked at either. Both leave the
database for something that treats them as a URL: the thumbnail is interpolated
into hand-built marker HTML, and the homepage is handed to window.open, where a
javascript: value would run in this origin rather than opening a page.

Both are allow-listed on the way in now, next to hexColorSchema and checked in
the handler the way validateRouteColor already is, on all three write surfaces
so REST, the plugin RPC and MCP agree on what they accept. The entity schemas
stay open: rows written before this still have to round-trip.

safeHttpUrl covers the read side for exactly those rows, the same split
safeHexColor uses, and the window.open calls it guards pick up
noopener,noreferrer while they are being touched.
2026-08-16 21:57:21 +02:00
Maurice f02a4b5654 feat(maps): bill a place search as one session instead of per keystroke
Google charges autocomplete per request unless the requests carry a session
token. With one, the keystrokes of a single search and the details lookup that
ends it are charged together, which is also how a user experiences it: they
looked up one place.

TREK sent no token at all, so every debounced keystroke was its own billable
request. On a busy trip that is the largest line on the Places bill, and it is
paid by whoever owns the key, which for a self-hosted instance is the person
running it.

A session opens with the first suggestion request and closes when the user picks
a result. It has to close on the abandoned paths too: a token carried into the
next search would merge two searches into one billed session and scope the
suggestions wrongly. So the search box clearing it, the text-search fallback and
the error paths all end it.

The token is validated on the way in. The autocomplete body goes through the Zod
pipe, the details query does not, so the controller shape-checks it there.
Anything that does not match Google's URL-safe 36-character form is dropped
rather than forwarded, which falls back to per-request billing instead of
breaking a lookup.

A cached details hit never reaches Google and so never closes its session. That
is billing-neutral: an unclosed session is charged as a plain autocomplete
session, which is still cheaper than the per-request behaviour it replaces.
2026-08-14 23:42:51 +02:00
Maurice 9dd0a851ca fix: eleven reported bugs across atlas, budget, collections, journey, vacay and the LLM settings (#1923)
* fix(mcp): accept per-leg times on create_transport and update_transport

metadata was pinned to Record<string, string>, so a legs array could not get
through and a multi-leg flight built over MCP lost every intermediate time. The
booking ended up with correct endpoints and no legs, which every reader then
treats as a single segment.

legs is now a real, typed parameter with its contract in @trek/shared, shaped
exactly the way the planner form and the importers write metadata.legs. The
folding checks what the readers depend on: one leg fewer than endpoints (the
AirTrail dedupe builds its signatures on that), day ids that belong to the trip,
airport codes that agree with their endpoint, and the times, where a stopover
endpoint carries the departure and the last one the arrival.

metadata keeps its type and its replace semantics. Segments smuggled through
metadata.legs as a JSON string used to be stored and then ignored by everything
that read them; they are refused now and point at the legs parameter instead.

Closes #1914

* fix(atlas): refuse a bucket list entry that is already on the list

Nothing gave a bucket entry an identity, so the same country could be added as
often as it was clicked. A wish is now identified by name, country, target date
and, where it has them, coordinates. A different target date is a different wish
and still lands, which is what the report asked for; two real places sharing a
name stay separate because their coordinates differ.

Empty string and NULL are normalised on both sides of the comparison, otherwise
the dateless case, which is the one that was reported, would slip through.

Deliberately no unique index and no migration. better-sqlite3 is synchronous and
the server is a single process, so the race an index would guard against cannot
happen here, and an index would have needed a destructive dedupe of existing
rows first.

create_bucket_list_item had no target_date parameter at all, so over MCP the
allowed "same place, other date" case was unreachable. It takes one now. The
plugin RPC reports the duplicate as bad params instead of letting it surface as
an internal error.

Closes #1898

* fix(settings): keep a free-form LLM endpoint in admin hands

Any account could name the address the server calls, and the guard in front of
it deliberately allows loopback and LAN so a self-hosted Ollama works. That is a
reasonable trade for whoever runs the instance, because the network is theirs. It
is not one for an arbitrary account, which could aim a request from inside that
network at a target only it chose and read how the target answered.

A free base URL, and the local provider that is one by another name, now come
only from an admin controlled source: the addon config, the instance-wide
defaults, or an admin's own settings. OpenAI and Anthropic stay available to
everyone, because they go to a fixed address with the user's own key.

The resolver is where this is enforced, since it is the single point both the
booking import and the plugin host pass through, and the only one that also
covers values already sitting in the database. The write path refuses as well,
so nothing is stored that would never be read.

The hint text promised something users no longer have, so it changed with it.

Closes #1772

* fix(calendar): span a date-only booking to its end date

A DATE event without DTEND is exactly one day per RFC 5545, so a multi-day
booking that carries an end date still collapsed onto its first day. It gets an
exclusive DTEND now, built the same way the stay branch already builds one. A
booking without an end date serialises byte for byte as before.

While in here: the stays query joins reservations on the accommodation, and two
bookings pointed at the same stay fanned it out into two events sharing one UID.
Duplicate UIDs in a feed are undefined behaviour and calendar clients pick one at
random.

The same "at most one booking per stay" assumption sat in the accommodations
service, where it was worse than cosmetic: accommodation_id carries no foreign
key, so deleting a stay took only the first booking and left the second pointing
at something that no longer existed. Both the sync and the delete walk all of
them now. The callers keep reading linkedReservationId; the list is additional.

Refs #1869

* fix(vacay): say that the region is required, because it is

The placeholder called the region optional while the add button stayed disabled
until one was picked. The wording predates multiple calendars per plan, which is
what introduced the gate. Only the value of the existing key changed, in all
locales, so the key set stays identical.

Two things the same screen got wrong were in the way of a clean answer:

The button was clickable while the region list was still loading, which put the
user in exactly the state the wording describes, and it is the state the report
was filed from. It waits for the list now.

Changing the country on an existing calendar kept the previous country's regions
on screen, so a German region could be picked for the Netherlands. The list is
cleared with the country, which only works in combination with the loading gate:
without it, the empty list would happen to enable the button instead.

Closes #1813

* fix(collections): let a saved place's address be corrected

The edit form offered name, category and labels, and the address only on the way
in. The cause was not the form: the update contract never declared address, so
the validation pipe stripped it and the UPDATE had no column for it. Coordinates
were pulled through for #1435 and the address was missed.

It travels the whole chain now, contract to service to both forms. No migration,
the column has always been there.

The mobile add sheet had no address input at all, showing whatever the search
result carried as read-only, so editing would have worked while creating still
would not. It has one.

The MCP tool description had drifted further than the address, never having
mentioned the coordinates either. Both are in it now.

Closes #1870

* fix(collections): keep the place search usable after the first save

The add dialog stays open on purpose so several places can be added in a row, but
after a save it gave no way back into the search. A search with no results was
silent, with no empty state and no toast, so typing another name and pressing
enter looked like nothing happened. The reporter described both halves and it is
the silent search that matches "nothing happens" most closely.

There is an empty state now, using a key that already existed in every locale and
was unused. The search row is sticky inside the dialog body, so it stays reachable
after the save instead of sitting above the scroll position.

Also fixed while in here: refreshing the collection after each add reset the
select mode, the selection and the label filter. In the very flow this dialog is
built for, the user quietly lost all three.

Closes #1921

* fix(vacay): make the phone year grid and the dock button do what they look like

Two separate faults met on this screen.

Tapping a day in the year grid did nothing unless the edit mode was already on,
and the only way into it was a floating button that looks exactly like the one
the dock draws in the same spot. A tap now opens that month in the editor, where
the cells are big enough to hit. At 390px the grid cells are around 21px, which
is too small to be mutating data with in the first place.

The dock's plus was not wired for this route, so it fell through to creating a
trip on the dashboard. Two different actions were stacked in one place, and the
wrong one was on top whenever the screen had not painted yet, which includes the
time its chunk is still loading. The dock leaves the slot to the screen now, and
holds the geometry so the tab groups stay symmetric.

Closes #1811

* fix(budget): settle in whole cents so balances stop drifting

The report is a balance that moves on its own, with nothing added, edited or
removed, on a trip where everything is in euro. Two mechanisms were behind it and
both are real.

The display currency is a fourth value, a per-user setting, so "everything is in
euro" never covered it. Each balance was converted and rounded on its own, and
the set no longer summed to zero: at 0.855 and 0.9312 it came out one cent over,
at 0.94 it came out even. That cent appears and disappears with the six hour rate
cache alone, which is exactly the symptom. The flows were rounded separately from
the balances, so the two never had to agree.

And a receipt line nobody was ticked for counted toward the total, and therefore
toward the payer's credit, while landing in no share. That difference is
permanent, and the client sent it that way.

The settlement converts once, at the edge, and does integer arithmetic after
that. Equal splits distribute the credited cents by largest remainder rather than
the entered total, so credits and debits of one expense cancel exactly even in a
foreign currency. Display conversion goes through the same largest-remainder
allocation, so the sum survives it.

With that, the 0.01 epsilon can go. It could never create a cent, but it stranded
them: a one cent balance was filtered out of the flows while staying visible in
the list, which is the "settle up offers nothing" half of the report.

Closes #1382

* fix(journey): reach the book export and the suggestion toggle on a phone

Below 1024px the hero card is dropped, and it held the only way to either action.
The floating bar that replaces it was never given them. It has them now, and the
hero is unmounted rather than hidden, so there is no second invisible copy of the
same controls in the document. The bar's own buttons moved onto the appearance
tokens while they were being touched.

The phone screen never had either action at all and now carries both.

The book itself was hardwired to English, cover and dates included, while the
keys for it had been sitting in every locale unused. It takes the page's
translator and locale, sets the document language, and prints mood and weather,
which were on screen but never in the book.

Closes #1848

* fix(journey): offer the connected photo providers in the phone entry editor

The desktop editor grew an external photo source in #1632; the phone editor that
came out of the mobile rewrite never did, so editing an entry on a phone had no
way to Immich or Synology. It mirrors the desktop mechanics, including the part
that is easy to miss: a save that creates the entry and then fails on the photos
keeps the sheet open, and without remembering what was just created the retry
creates a second entry.

The sheet no longer kicks off its own addon load. The list is loaded once on
boot and this only reacts to it; a second load races the other consumers and
overwrites the store when it lands.

Closes #1808

* fix(mobile): let the document scroll so Safari can collapse its URL bar

iOS Safari only shrinks its address bar when the document scrolls, and TREK's
never did: the root was locked and the mobile shell put a scroll container inside
it. On the bottom tab layout that costs about a sixth of the viewport,
permanently.

Below 768px the document is the scroller again and the full-bleed screens measure
their own height instead of leaning on the shell.

That makes the body scroll locks real for the first time on a phone, and they
were not ready for it. Three of them reset the overflow unconditionally, one of
those on every run of its effect rather than on unmount, so a system notice could
hand the page back while a sheet was still open. They all go through one
reference counted lock now, which is the part that had to land first.

Worth knowing for the issue: this reaches the flow screens. The trip planner, the
journal, the atlas and the vacay screen keep their own full-height scrollers and
still will not collapse the bar.

Closes #1809

* fix(mobile): keep the screen gradient visible behind the translucent cards

The gradient layer introduced with the scroll change sat at z-index -10. A
negative layer paints above the canvas but below the backgrounds of ordinary
blocks, and body carries an opaque one, so the gradient was hidden behind it.
The mobile cards are translucent and take their depth from what is behind them,
so every surface ended up compositing against a single flat colour and the whole
phone UI read as one tone.

The layer is positioned at z-index 0 now and the content above it at 10, which
puts both over body's background. Same visual as before the scroll change, and
the shell still owns no scroll container.

* test(reservations): teach the accommodation delete stub about the id lists

The RPC walks every linked booking since the delete stopped stopping at the
first one. The stub still answered with the two singular fields, so the loop had
nothing to iterate and the cascade events never fired.

* fix(settings): move the free-form LLM endpoint out of user settings entirely

Hiding it from non-admins closed the hole, but it left the option sitting in the
admin's own personal settings, which is not what was asked for and not where it
belongs: an instance has exactly one address it calls out to, and there was a
second, invisible place to set it.

The provider list is now the two hosted ones for everybody, the endpoint field is
gone, and the resolver takes the address from the admin-controlled instance
defaults for every caller including an admin. It no longer reads the users table
at all. The write path refuses the keys for everyone, so nothing can be stored
that would never be read.

The endpoint is configured on the addon in the admin settings, which already
carries provider, model, base URL and key, plus the Ollama model list. Nothing
is lost, it just has one home now.

Behaviour change worth a release note: an admin who set a personal Ollama
endpoint has to move it to the addon config. A non-admin was already in that
position after the previous commit.

Refs #1772

* fix(journey): make the book preview usable on a phone

The preview document carried no viewport, so a phone laid it out at its default
980px while the page is A4 landscape and about 1123px wide. It spilled out of
the frame, which reads as being zoomed far in. Declaring the real width lets the
browser scale the page to fit. Printing is unaffected, it goes by @page.

The preview chrome had the same problem in miniature: a title and two labels on
one row, sized inline, which does not fit a phone and fits translated labels
even less. It moved to a stylesheet, and below 640px the card goes full-bleed,
the decorative title steps aside and the two actions share the width.

Refs #1848

* fix(mobile): stop the shell trapping the screens' own overlays

Lifting the background layer into the positioned range meant the content wrapper
had to be positioned too, and a positioned wrapper with a z-index is a stacking
context. Every fixed overlay a screen renders inside it got clamped to that
level: the vacay view/edit FAB sits at z-50 precisely so it beats the dock at
z-40, and it disappeared behind it.

The layer goes back to a negative z-index and the wrapper back to no positioning
at all. What it needed in the first place was for body to stop painting over it,
which is one line in the phone media block rather than a new stacking context.

* fix(mobile): let the phone background override actually win

A media query adds no specificity, so the themed body rule further down the file
kept winning on source order and painted over the shell's background layer. The
override is on html body now.

The gradient was back to being invisible, which is the flat single-tone phone UI
again.

* fix(vacay): draw today's ring around the day instead of replacing it

On the phone, today short-circuited the day-cell matrix and returned a ring on an
empty cell. A vacation day or a company holiday logged for today was stored and
counted in the totals, but the cell showed nothing, so it read as if the tap had
not taken.

Today is an annotation now, layered like the school band and the shared-calendar
rings, which is how the desktop month card has always treated it. The strong ink
stays only where the cell has no fill of its own, since --m-ink is light in dark
mode and would disappear on a pastel.

* fix(map): stop serving a stale GL style document from the service worker

The Workbox rule for Mapbox matched every URL under api.mapbox.com, and the
style document lives there too. Under StaleWhileRevalidate the map was built
from the previous revision on every load, with the refresh landing one load too
late, so a style republished in Studio with different label languages kept
rendering the old ones. The network tab showed a 200 throughout, because the
service worker's own answer is a real 200, which is why this looked like the app
rewriting text-field at runtime.

The style now has its own rule ahead of the tile rules, on NetworkFirst so it is
current whenever the network is, and still cached so the map opens offline. Its
own small cache too: sharing an LRU with three thousand tiles made the staleness
come and go at random. OpenFreeMap styles had the same problem and are covered
by the same rule.

Closes #1924
2026-08-13 14:06:35 +02:00
Maurice e7851e79ff fix(client): honour the start page on a device that never mirrored it
The mirror answered "dashboard" for a browser that had simply never been
told, and RootRedirect took that as the user's choice - so the setting was
ignored on every device except the one that set it, and on that one too
after a logout cleared the mirror. The setting itself was stored correctly
the whole time, which is what made it look like nothing happened.

readStartDestination now says "unknown" rather than guessing, and a device
in that state waits for the real settings instead. That costs one wait,
once per device; afterwards the mirror answers and the launch is as fast
as before. Bounded by a timeout, because loadSettings deliberately leaves
isLoaded false on a failed request so it can retry - without the floor, a
launch with no backend would sit on the spinner forever.
2026-08-08 23:01:40 +02:00
Maurice 44eff7177e feat(client): open TREK on the active trip when asked to
Settings -> General -> Startup: start on the dashboard (unchanged default)
or straight in the active trip, on a tab of your choice. Three clicks and
three loads less for someone entering expenses while actually travelling,
which is where the request came from.

The redirect sits on '/' because that is what a shortcut, a bookmark and
the installed PWA all launch (manifest start_url). /dashboard keeps
working as itself - redirecting there too would leave no way back to it.

The preference is mirrored into localStorage, the way the language and
the theme snapshot already are, so the first paint can decide without
waiting on GET /api/settings. That keeps the default launch exactly as
fast as before: only someone who asked for the trip pays for the lookup,
and if it fails or they have no trip they get the dashboard. Right after
login the store already holds the real settings, so those win there.

Login stops hardcoding /dashboard as its fallback and hands the decision
to the same place, so there is one answer to "where does TREK open".
2026-08-08 23:01:40 +02:00
Maurice cf62567c48 fix(map): stop untrusted values reaching the marker HTML (#1820)
* fix(map): drop the casing layer before its source on teardown

Tearing down the reservation overlay logged an error on every map unmount:

  Source "trek-reservations" cannot be removed while layer
  "trek-reservations-lines-transit-casing" is using it.

The casing layer arrived with the transit paths in 3.2.0 but was never added to
destroy(), which only removed the main line layer. The try/catch around it gave
no protection either — the engine reports this by firing an error event rather
than throwing — so the source was silently left behind on the style.

The layer id is a named constant now, so the place that creates it and the place
that removes it can no longer drift apart.

The lifecycle test grew the assertion that would have caught it: the fake map
now refuses to remove a source that a layer still references, the same way the
real one does. Verified against the unfixed code — two tests fail without the
change.

* fix(map): stop untrusted values reaching the marker HTML

Both map renderers build their marker HTML as a string and hand it to
L.divIcon / innerHTML. Two of the interpolations carry values a user controls,
and neither was escaped.

place.image_url is the worse of the two, because it is not admin-only: any trip
member with edit rights can set it. placeUpdateRequestSchema is a plain record
of unknown values, and the length guard in places.controller covers name,
description, address and notes but not image_url. The renderers do check the
value, but only with startsWith('/uploads/') — a payload like

  /uploads/x" onerror="…" y="

satisfies that and then breaks out of the src="…" attribute.

category.color is the same shape one line up, reachable by an admin. It also
reaches SharedTripPage, which builds its own divIcon and answers without a
guard, so that payload lands in front of people who have no account here.

Colours are allow-listed rather than escaped. Escaping stops the attribute
breakout but leaves a working CSS value behind, and `url(https://…)` in a
background is a tracking pixel. Anything that is not #rgb/#rrggbb falls back.
image_url is a URL, not an enum, so that one is escaped; the prefix check stays.

Server side, the category contract now demands a hex colour, and the endpoints
validate through DTO classes instead of @Body('color') — a property read carries
no metatype, so the global pipe never saw it. That retires two entries from the
body-contract allow list. It replaces the hand-rolled 400 for a missing name
with the pipe's envelope, the same trade the places and trips migrations made.

The response schema deliberately still accepts any string: the write path was
open for a long time, so tightening it would make an instance fail on its own
stored rows. The client allow-list is what covers those.

Verified against the unpatched renderer — the three new cases fail without it.

* perf(client): serialize the map icons without react-dom/server

Six modules imported react-dom/server statically to turn a lucide icon into an
SVG string. That put Fizz — roughly 190 kB raw, 57 kB gzip — into a chunk three
lazy routes share, the Leaflet renderer among them, which is the default. For
output that is always a single <svg> with a flat list of shape children.

  mapViewport chunk   189,532 -> 3,710 B
  Tooltip chunk       194,333 -> 1,970 + 4,860 B
  precache total      17,856 -> 17,675 KiB

utils/iconMarkup.ts serializes React elements directly. It is generic over
elements rather than over lucide's icon tables on purpose: iconNode is closed
over inside createLucideIcon and never exported, so calling the forwardRef
render function is what survives a lucide upgrade.

Byte-compatibility is checked, not asserted. iconMarkup.parity.test.ts diffs the
output against real Fizz for every exported icon in three prop shapes — the test
file is the one place still allowed to import react-dom/server, and it never
reaches a bundle. Two traps it covers: viewBox must not be kebab-cased into
view-box, and Fizz emits <path …></path> rather than a self-closing tag.

TripPDF loses its lazy renderer entirely — ensureRenderer, the module-level
handle and the two "not ready yet" guards that returned an empty string. There
is nothing left to load.

An eslint rule keeps the import from creeping back. Without it the regression is
invisible: the build stays green, the app keeps working, and the chunk quietly
grows by 190 kB again.
2026-08-07 13:43:38 +02:00
Maurice 96e4ff5932 perf(client): load the page chunks on demand (#1812)
* feat(client): retry a failed page chunk once before giving up

A dynamic import fails for two different reasons, and only one of them is
worth a second attempt. If the file is gone because a deploy happened while
the tab was open, nothing but a reload helps and reloadOnceForChunk already
owns that path. If the fetch itself went wrong — hotel wifi, an aborted
request, a service worker handing back a truncated entry — one retry fixes
it.

The retry cannot reuse the original specifier, since Vite rewrites it into a
hashed asset URL at build time; the only usable URL is the one the browser
names in the failure message, and a fresh query on it misses both the HTTP
cache and the Workbox precache.

This is also where a healthy load is acknowledged. Clearing the reload marker
used to sit in main.tsx right after render(), which runs before React commits
and long before any chunk is fetched, so the marker was gone before it was
ever needed.

* perf(client): load the page chunks on demand

Every one of the twenty pages was a static import, so the entry chunk carried
the planner, the journal, the atlas and the vacation planner for anyone who
only opened the dashboard. They now load through lazyWithRetry; the entry
chunk drops from 5,183 kB to 1,243 kB (gzip 1,300 -> 349).

LoginPage stays static on purpose: anyone logged out lands there, and every
other route redirects there first.

The Suspense boundary sits above <Routes> rather than inside ProtectedRoute
so it stays mounted across navigations. react-router runs location updates
inside a transition, which keeps the current page on screen while the next
chunk arrives instead of flashing a spinner on every jump.

The precache is unchanged by design: workbox picks up the new chunks, so the
install size stays where it was (107 -> 178 entries, +31 KiB) and every route
remains available offline. This buys first paint, not install size.
2026-08-07 10:22:51 +02:00
Maurice 39b6d9f786 feat(client): catch render errors instead of blanking the page (#1805)
* feat(client): catch render errors instead of blanking the page

There were no error boundaries anywhere, so one throw during render unmounted the
whole tree and left the user staring at a white page with no way back.

ErrorBoundary holds the state in a class — getDerivedStateFromError has no hook
equivalent — and renders a function component for the visible half, so the panel
can translate normally. TranslationContext isn't exported, so contextType was not
an option anyway.

Two things it does beyond showing a message. A chunk that 404s after a deploy gets
one reload rather than a retry button: React.lazy caches the rejected promise, so
retrying re-throws immediately. And the reload happens once per session, or a
genuinely missing chunk would spin the tab.

The global handlers in main.tsx cover what no boundary reaches — event handlers
and async code. They filter axios errors: the interceptor already redirects on
401/403 and there are 600+ toast.error call sites, so reporting again would mean
double toasts and, for the redirects, loops.

* feat(client): isolate routes, widgets and the shell chrome

The route boundary goes on the single line where all 16 protected routes render
their content, inside MobileShell rather than around it: the navigation survives,
so a broken page is something the user can walk away from instead of a dead end.
The mobile --m-* tokens live on the shell's .m-root too, so a fallback outside it
would render invisible.

key={location.pathname}, not resetKeys — ProtectedRoute is the same component at
the same position for every protected route, so otherwise a failure could follow
the user across a navigation.

The public routes render outside ProtectedRoute and were therefore uncovered,
including /login (where people land when everything else failed) and the two
anonymous share pages. PublicRoute wraps those seven.

The five global widgets and the shell chrome get fallback={null} each: a broken
widget should disappear quietly, and if BottomNav dies the user loses the very
thing they need to leave the route.

* feat(client): isolate the GL maps and the plugin frame

Both map wrappers put the boundary outside their Suspense, not inside: Suspense
handles a pending promise, a rejected one throws straight past it. Falling back to
the Leaflet renderer keeps a usable map rather than showing an error card — the
same downgrade the Suspense fallback already does while loading.

PluginFrame is third-party code rendering in our tree; a throw there should cost
the plugin, not the page. resetKeys={[pluginId]} so switching plugins clears it.

The locale loader had no .catch (TranslationContext.tsx). After a deploy the chunk
can be gone, which produced an unhandled rejection and left the UI silently on the
previous language. It now keeps the strings it has and says so in the console.
2026-08-06 23:42:57 +02:00
Maurice edea67ff7e fix(planner): let tablets drag places into the day plan (#1616)
Dragging a place from the right pane to the left one did nothing on an
iPad or a Samsung tablet — the row was selected as text instead. Phones
never reach this layout, they get the mobile shell below md, so the coarse
pointer check in dragDisabled was only ever switching tablets off.

It was also doing that for a reason that had gone away. What swallowed the
scroll gesture in #1432 was the drag-drop-touch polyfill, and that has been
confined to hybrid laptops since. Width is the gate that still means
something: below lg the plan and the places are separate tabs, so there is
nothing to drag between.

So touch is no longer a reason to disable the drag, and the planner arms
the long-press bridge instead where the pointer is coarse. Rows get
-webkit-touch-callout and user-select off there too, otherwise the press
that starts the drag brings up the selection first. The isTouch prop had no
other use and is gone, along with a comment claiming arrow reorder buttons
take over on touch — there are none.
2026-07-29 19:15:57 +02:00
Maurice e7ccbcdf86 feat(planner): add a long-press touch drag bridge
A finger never starts an HTML5 drag on Android, and the answer used to be
drag-drop-touch: a document-wide polyfill that turned every touch over a
draggable element into a drag. That cost the places list its scrolling
(#1432) and the map its one-finger pan (#1440), so it now only loads on
hybrid laptops.

This is the narrow version. It only watches touches that begin on a
draggable row inside a container that opted in, and it waits out a long
press before claiming the gesture, so a swipe is still a swipe. Once armed
it replays the real drag sequence on the element under the finger, which
leaves every existing drop handler working as it is.

Nothing is wired up to it yet.
2026-07-29 19:15:57 +02:00
Maurice aaef3e3e76 fix(reservations): honour the 24h time setting (#1725)
The 12h/24h rules lived in three copies and two of them ignored the setting.
They are one function now. splitReservationDateTime also truncated the
meridiem instead of converting it, so 3:00 PM became 3:00 — three in the
morning. Values already stored that way normalise when a form renders the
field, without changing the payload or the stored format.
2026-07-29 15:14:21 +02:00
Maurice 9ecca404c5 fix(map): drop the retired OpenStreetMap tile subdomains (#1733)
The a/b/c shards stopped being necessary in 2022 and d has since lost its
DNS record. The map never asked for d — Leaflet defaults to abc — but the
offline prefetcher rotated over a to d, which is where the failing requests
came from, and it cached most tiles under a host the map never requests.

Presets and the prefetcher use the apex host now. A URL already saved in
user_settings is rewritten where it is read and written back, so existing
instances heal without a migration touching what someone typed themselves.

The CSP only allowed https://*.tile.openstreetmap.org, and a wildcard host
never matches the apex domain, so the prefetch fetch would have been blocked.
The service worker rule had the same gap and would have stopped filling the
tile cache.
2026-07-29 15:14:21 +02:00
Maurice 72a82b3c44 fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
2026-07-29 12:35:00 +02:00
Maurice 1e66bcc701 test(pages): cover the page hooks, stores and the api client 2026-07-29 11:55:31 +02:00
Konstantinos Thermos 88cb7f22dd fix(budget): match the add-expense input decimal separator to the list (#1645) 2026-07-21 20:36:47 +02:00
jubnl 91095ef96a v3.4.1 (#1606)
* fix(transit): lead arrive-by results with the latest arrival (#1479)

MOTIS returns arrive-by itineraries ascending with the deadline-adjacent
connection last, so the visible top of the list arrived ~2h before the
requested time — misread as a timezone bug. Sort arrive-by results by
endTime descending so the connection closest to the requested arrival
leads, mirroring depart-by.

Claude-Session: https://claude.ai/code/session_01BCrZUoPzHZF6H4C6avMXoz

* fix(transit): lead arrive-by results with the latest arrival (#1479)

MOTIS returns arrive-by itineraries ascending with the deadline-adjacent
connection last, so the visible top of the list arrived ~2h before the
requested time — misread as a timezone bug. Sort arrive-by results by
endTime descending so the connection closest to the requested arrival
leads, mirroring depart-by.

* fix(reservations): run AirTrail modal hooks before the isOpen early return (#1602)

The sectionItems useMemo added by #1535 sat below 'if (!isOpen) return null'.
The modal mounts closed, so the first open render executed one more hook
than the previous render and React unmounted the whole tree (error #310),
blanking the page. Move the memo above the early return and cover the
closed-then-open transition with a regression test.

* fix(planner): only draw the check-in-day hotel morning leg when provably at/after check-in (#1597) (#1607)

On an accommodation's check-in day the hotel -> first-stop leg was drawn
by default for any PLACE first stop, suppressed only when that place was
explicitly timed before check-in. An un-timed first place (e.g. "Home"
on day 1 of a driving holiday) therefore always produced a phantom
hotel -> Home leg, regardless of the check-in time.

Flip the default to mirror shouldDrawEveningLeg: the morning leg is now
drawn only when the first place is provably timed at/after check-in
(you dropped your bags first). The drawn map route, the sidebar hotel
connectors, and the Google Maps export all share this helper and
inherit the fix. The optimizer anchors (#1321) are unchanged.

* fix(notifications): never fall back to the admin ntfy topic for per-user sends (#1608)

* chore: correct shields.io url

* chore: correct shields.io url

* chore: update helm repo link

* chore: document new helm chart url

* chore: document new helm chart url

* fix(atlas): make Kosovo selectable on the Atlas map (#1609)

Kosovo's user-assigned ISO code (XK/XKX) was missing from both code
tables in the atlas pipeline: the geo builder's A3_TO_A2 map (so the
shipped admin0 bundle carried ISO_A2: null) and the client's A2_TO_A3
map. With neither resolvable, onEachFeature attached no hover/click
handlers to Kosovo's polygon and the country search dropped it, making
Kosovo impossible to select.

- add XK<->XKX to the client A2_TO_A3 table and the builder A3_TO_A2 map
- stamp ISO_A2: "XK" on the Kosovo feature in the shipped admin0 bundle
- guard both with tests (atlasModel resolution + ATLAS-BUNDLE-003)

* chore: normalize docker image references to mauriceboe/trek (lowercase)

* fix(memories): honor Synology skip-SSL on photo streaming (#1611)

The synology_skip_ssl setting was forwarded on the JSON API path
(login/browse/test-connection) but not on the image-byte fetches, so a
NAS with a self-signed certificate passed Test Connection while
/api/photos/:id/thumbnail and /original failed with 500
"Failed to fetch asset" — and the TLS error was silently swallowed.

- pipeAsset: accept fetchOptions and forward to safeFetch; log the
  underlying error on the 500 path (without the URL, which carries _sid)
- fetchSynologyThumbnailBytes / streamSynologyAsset: pass
  rejectUnauthorized derived from synology_skip_ssl; log on failure
- i18n: replace hardcoded "Immich" with {provider_name} in 7 memories
  keys across 14 locales (source of the reported "Failed to connect to
  Immich" banner on the Synology test), fix translated placeholder
  names in tr/ca/pl, and add a placeholder-parity spec to prevent
  regressions
- client: gallery provider badge matched 'synology' instead of the real
  id 'synologyphotos', showing the raw id; label is now
  "Synology Photos"

* chore: make chart.liketrek.com the canonical helm chart url

* feat(plugins): prefer the registry's resolved store screenshot (#1613)

Browse/detail use the screenshotUrl the aggregate step resolves (cover at the latest commit, or the first resolving README image), falling back to the docs/screenshot.png guess when the field is absent. Fixes blank store cards for plugins without a committed docs/screenshot.png.

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
2026-07-19 19:00:40 +02:00