Commit Graph

882 Commits

Author SHA1 Message Date
Maurice eb8e777741 chore(client): move zustand to 5.0.14 (#1825)
The last React-18 leftover in the dependency tree. v4 declared
use-sync-external-store as a hard dependency and its main entry imported the
shim; v5 declares no dependencies at all and calls React.useSyncExternalStore
directly. Only zustand/traditional still reaches for the shim, and nothing here
imports it, so it no longer reaches the bundle.

The upgrade itself needed one line. What it needed more of was checking, because
the one thing v5 changes about behaviour does not show up as a test failure.

v4 subscribed through useSyncExternalStoreWithSelector, which memoised the
selector's result. v5 hands React a plain getSnapshot of
`() => selector(api.getState())` and compares with Object.is. A selector that
builds a fresh object or array therefore returns a new reference on every call,
and React reads that as "the store changed" — an infinite render loop, or at
best a re-render on every parent render.

All 294 store selectors return a property or a primitive. None builds an object
literal, array literal, filter/map/slice result, `?? []` default, Object.keys or
a new Set/Map, so none of them needed useShallow. The suite confirms it: 533
files, 11,165 tests, and zero "getSnapshot should be cached" or "Maximum update
depth" warnings anywhere in the output.

The one source change is the seedStore test helper, which was typed against a
hand-written `{ setState: (partial, replace?: boolean) => void }` shape. As of
v5 setState is an overload pair — partial with replace?: false, full state with
replace: true — and no single signature is assignable to that. Typing it against
zustand's own StoreApi fixes all 129 resulting errors and is what the helper
meant in the first place.
2026-08-07 16:34:18 +02:00
Maurice 3dfbb3fd02 perf(planner): load the PDF export and the transport modal on demand (#1821)
Two more things every trip paid for whether or not anyone used them.

downloadTripPDF hung statically off the days sidebar, so opening any trip
pulled in the whole PDF builder. Both call sites are already async and already
wrap the call in try/catch, so a failed chunk surfaces as the export error the
handler had anyway.

TransportModal was already rendered conditionally, which means lazy bites
immediately. It is worth more than its own 32 kB: it is the only path to
TransitSearchPanel, and that chunk carried tz-lookup — about 200 kB of packed
zone geometry.

  TripPlannerPage      339.78 -> 305.70 kB
  entry chunk          258.24 -> 218.70 kB
  TransitSearchPanel   198.67 ->  26.51 kB   (and now on demand)
  TripPDF              226.02 ->  27.78 kB   (and now on demand)
  eager path           unchanged at 1,483 kB

Worth stating plainly: tz-lookup itself did not go away. It sits in
PlaceFormModal.helpers now, and PlaceFormModal is still a static import of the
planner, so a trip still loads it. Prying that loose means touching
placeOpenState, whose consumers assume a synchronous lookup — a separate change
with its own care.
2026-08-07 14:38:25 +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 c089e75f08 perf(map): load one GL engine instead of both (#1819)
* perf(map): load one GL engine instead of both

MapViewGL, JourneyMapGL and the settings preview each imported mapbox-gl and
maplibre-gl statically, so rollup emitted one 2.83 MB chunk carrying both.
Every map user downloaded 1.8 MB of mapbox plus 1.03 MB of maplibre and then
ran exactly one of them. The 110 kB stylesheet was merged the same way.

  mapbox users     2,827,647 -> 1,797,952 B   (-1.03 MB, CSS 110.6 -> 40.8 kB)
  maplibre users   2,827,647 -> 1,027,635 B   (-1.80 MB, CSS 110.6 -> 69.8 kB)

The engine is a prop now rather than a module import, and the pairing happens
behind the lazy boundary in glLazy.tsx. No module reaches both SDKs statically
any more, which is the whole mechanism — the components themselves stayed
engine-agnostic, they already guarded on the provider everywhere the two
genuinely differ.

Both imports of a pair start together instead of in sequence: the engine is by
far the larger half, and fetching the component first would put its round trip
in front of the download that actually costs time.

check:gl-split guards it. A stray static import anywhere in the map tree brings
the merged chunk straight back with a green build and a working app, so the
script greps the built chunks for a marker of each SDK and fails when one file
holds both. Not on the bare string "maplibre" — the provider value and the
maplibre_style setting key appear in app code too, so that matches the shared
core chunk as well.

Two boundaries were missing and are added here, because the split makes them
reachable: the GL boundaries had no resetKeys, so a chunk failure under one
provider kept showing Leaflet after switching to the other, and the settings
preview had a Suspense but no boundary at all — that is the one place a user
flips providers live, so it is the likeliest chunk to fail.

Worth being precise about the gain: the service worker precaches every chunk,
so an installed PWA still downloads both engines. What this buys is the
critical path on a cold cache, and parse/compile/execute always — a megabyte
of unused engine ran on every map open.

* perf(client): split out the video player and the ISO subdivision data

Two heavyweights that shipped with routes that rarely use them.

plyr (112 kB raw / 33 kB gzip, plus 32 kB of CSS) hung off three lightboxes and
through them off the FileManager, JournalBody and MTripShell chunks. The
lightboxes only render the player on their video branch anyway, so the split
costs one frame of placeholder — a black rectangle in the target geometry, so
the lightbox does not jump when the chunk lands.

iso-3166-2 is a single 238 kB data blob with no tree-shaking to be had, and
only fetchRegionOptions touches it. It now loads after the hasRegions check,
so it is fetched only for countries that get a region picker at all:
holidayRegions drops from 257 kB to 14 kB. The import sits inside the existing
try — a failed chunk yields the same empty array as a failed request, rather
than an unhandled rejection in the two callers that only do .then(setRegions).

tz-lookup is deliberately left alone. TransitSearchPanel builds the departure
time it sends to MOTIS out of tzAt(), so a not-yet-loaded window there is a
wrong query rather than a late pixel — that one needs its own change with its
own care.
2026-08-07 12:46:03 +02:00
Maurice 49e68fb07c build(client): give react and the core libs their own chunks (#1818)
Everything that changes with a release currently drags the entry chunk with
it, and 63 of 141 chunks import the entry statically. Measured with a
one-token edit to a leaf route module and a rebuild, 70 of 141 chunks rehash —
just over 1 MB gzip that every installed PWA re-downloads per deploy, since
the service worker registers with autoUpdate.

Pulling react and the core libraries out cuts that to 847 kB:

  rehashed   70/141 -> 60/140
  gzip       1,038,497 B -> 847,688 B   (-18.4%)
  entry      580,672 B -> 258,105 B
  eager JS   1,482,722 B -> 1,482,523 B (unchanged)
  eager CSS  same file, same hash

No first paint win, then — the gain is a smaller update delta and a halved
entry to parse on weak devices.

Three of the five groups the plan called for are left out, and not silently:
mapbox-gl/maplibre-gl, leaflet and react-markdown already sit in async chunks
whose hashes survive a release, so a group would only rename them. Worse,
without tags: ['$initial'] a group also collects modules that hang behind
React.lazy today and turns its chunk into a static import of the entry —
measured, that put the 2.8 MB GL chunk into the index.html modulepreload and
took eager JS to 4.61 MB, undoing the route split for maps.

Note for the next person to touch this: vite 8 bundles with rolldown, so
rollupOptions is only an alias and both manualChunks and advancedChunks are
deprecated in favour of codeSplitting.groups. A config using the old names
builds green and has no effect.

Also refreshes two comments that still quoted pre-split bundle numbers.
2026-08-07 12:01:43 +02:00
Maurice 057d4ba503 perf(client): pick the viewport branch at the route (#1817)
Every page imported its mobile screen statically and chose while rendering, so
each route chunk carried both trees and the viewport only decided which half
stayed dark. Worse, because ten route chunks shared the mobile tree, rollup
hoisted it into the entry chunk — so every desktop user downloaded the whole
mobile shell before seeing a single page.

The branch now decides the chunk. A phone never fetches the desktop planner, a
desktop never the mobile shell.

  entry chunk        1,243 -> 581 kB   (gzip 349 -> 177)
  TripPlannerPage      914 -> 340 kB
  VacayPage            361 ->  57 kB
  AdminPage            413 -> 225 kB
  SettingsPage         259 -> 155 kB

Splitting inside each page instead was measured and rejected: it leaves the
desktop code in the same module as the switch, so a phone would still download
it and then wait for a second, serial request on top — 914 kB and a waterfall
against 550 kB in parallel.

The pass-through wrapper in each page stays deliberately. check-page-pattern
scans from the default export to the next top-level function and rejects hooks
there; making the desktop component the default export fails it immediately on
dashboard, planner and atlas.

RouteFallback gets a mobile face. It renders above MobileShell, so the --m-*
tokens do not resolve for it and the desktop spinner on bg-surface would be a
foreign white sheet in front of the mobile screen.

The five scattered viewport spot checks are replaced by one contract covering
all eleven routes, which is where the switch now lives.
2026-08-07 11:51:37 +02:00
Maurice 1d94814b57 perf(planner): load the tab panels on demand (#1816)
* refactor(costs): move the pure split maths out of the panel module

splitEqualShares, calculateTicketShares and TicketItem sit next to the payer
maths they belong with. They are pure functions with no module state and no
imports from the panel, so this is a move and nothing else.

The reason it matters: the mobile cost sheet and its model import these two
functions from CostsPanel, and MTripShell is a static import of the trip
planner page. Any attempt to load the costs panel on demand would therefore
pull the whole 97 kB module straight back in through the mobile branch.

* perf(planner): load the tab panels on demand

Each tab panel only mounts while its own tab is active, yet all six shipped in
the planner chunk. The page was 1,203 kB after route splitting — larger than
the entire entry chunk. It is now 914 kB, with the panels in six chunks of
their own (files 99, collab 87, packing 71, costs 62, reservations 32,
todo 31 kB).

The panels also get the error boundaries they never had. fe-error-boundary
covered the maps and the plugin frame but not these, so without them a dead
panel chunk would throw past to the route boundary and take the whole planner
with it. The boundary sits outside the Suspense, since Suspense only owns the
pending promise and a rejected one throws straight past it.

One boundary per panel rather than one around the content area: a shared one
would already be mounted with the visible tab, so switching tabs would swap
the entire planner for the placeholder instead of just the part still loading.

PluginFrame stays static — DayDetailPanel and PlaceInspector import it from
the plan tab anyway, so splitting it here would move nothing. The two import
modals stay static for the same reason, via the mobile sheets.

The wiring tests now await their panel. That includes the ones still passing:
React.lazy caches the resolved module for the whole file, so they were only
green because an earlier test had already pulled it in.
2026-08-07 10:37:10 +02:00
Maurice e5962c22d5 chore(client): tighten the precache ceiling and correct the offline docs (#1815)
* chore(client): tighten the precache ceiling now the entry chunk is split

The 6 MB ceiling was set against the pre-split bundle, where the entry chunk
alone was 5.2 MB. It is now 1.2 MB and the largest precached entry is the
heic-to chunk at 3.0 MB, so the old limit had nothing left to catch.

Worth being explicit about what the limit does: a file above it is dropped
from the manifest and the build still succeeds, printing only that the file
will not be precached. The PWA is then quietly broken offline for that file,
which is why the ceiling has to stay close to the real bundle.

Also records why every route chunk is precached rather than pulled in at
runtime, since that is the reason route splitting did not shrink the install.

* docs(wiki): correct the offline cache table

The table still described an api-data cache holding API responses for 24
hours under NetworkFirst. That cache is gone: API requests are NetworkOnly
because Workbox keys entries by URL and cannot vary them on the session
cookie, so on a shared device one account's data could be served to the next.
Documenting a cache that deliberately does not exist is the wrong way round
for a privacy decision.

Map tiles were listed at 1 000 entries; the actual cap is 12 288 and has to
stay in step with MAX_TILES in tilePrefetcher.ts. The two GL tile caches were
missing altogether, and the precache line now says that it covers every page
rather than just the shell.
2026-08-07 10:25:44 +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 6c63a9bb60 fix(client): move to react-router 7.18.2 (#1804)
* refactor(client): opt into the v7 router behaviour while still on v6

Turning the behaviour on separately from the version bump keeps the two failure
modes apart: red here means a behaviour change, red on the bump means the import
rewrite. The suite is unchanged either way — 527 files, 11099 tests.

Only the two flags a non-data router understands are set; the rest belong to
createBrowserRouter, which this app doesn't use. Both are applied at all six
router mounts, so the tests exercise the same behaviour as production rather than
validating v6 while prod runs v7.

* fix(client): move to react-router 7.18.2

6.30.4 is affected by GHSA-wrjc-x8rr-h8h6 — open redirect through a backslash in
Link and useNavigate, which this app uses in 41 places — and the 6.x line has no
fix: react-router-dom 6.30.2 to 6.30.4 carries GHSA-jjmj-jmhj-qwj2 with no patched
version at all. Staying is not an option, and 7.0 to 7.17 only swaps it for
GHSA-chx6-hx7r-mcp5.

The package rename comes with it: react-router-dom is a re-export shim in v7, so
using it would only defer the same rewrite to the v8 jump. 77 occurrences across
60 files, all of them import specifiers and vi.mock strings.

The surface is small — useNavigate, useParams, useSearchParams, useLocation,
Routes, Route, Link, Navigate, useMatch, MemoryRouter, BrowserRouter — no data
router, no loaders or actions. Nothing else had to change; the future flags from
the previous commit are simply how v7 behaves, so they come back out.

npm audit still reports GHSA-qwww-vcr4-c8h2 on react-router (RSC mode CSRF, fixed
in 8.3.0). It needs RSC mode, which needs a server; this is a static SPA. Closing
it means the v8 major, which is its own piece of work.
2026-08-06 23:01:58 +02:00
Maurice 2b11990063 chore(client): take the GL engines out of the entry chunk (#1801)
* refactor(settings): load the GL map preview on demand

MapViewGL and JourneyMapGL are both behind lazy() so a Leaflet-only install
never downloads the GL engines. MapboxPreview imported mapbox-gl and maplibre-gl
statically, which put them back in the entry chunk and cancelled that out.

Wrap it in lazy()/Suspense on both the desktop tab and the mobile screen. The
entry chunk drops from 7987 kB to 5157 kB (2057 kB to 1292 kB gzipped); the
engines now ship as their own chunk, loaded when a GL provider is picked.

The preview no longer resolves synchronously, so the tests that read it right
after switching provider await it now.

* chore(client): drop unused deps, self-host MuseoModerno, add a bundle baseline

@react-pdf/renderer, react-window and @types/react-window have no importers
anywhere: TripPDF builds HTML for the browser print window and JourneyBookPDF
goes through marked. RegisterPage is dead too, /register renders LoginPage.

MuseoModerno was the last font still coming from the Google CDN through a
render-blocking <link>, while main.tsx already self-hosts Poppins and Geist for
exactly the reason stated in the comment there. Same weights, now bundled.

Add rollup-plugin-visualizer behind `npm run build:analyze` so the splitting work
has numbers to argue with, pin build.target instead of inheriting Vite's default,
and bring the workbox size ceiling down from 10 MB to 6 MB — anything above it is
dropped from the precache manifest silently, which only shows up as a broken
offline app.
2026-08-06 22:25:12 +02:00
Maurice 06f5bc2c86 fix(planner): keep the file-upload callback above the place bail-out (#1800)
PlaceInspector returns null when no place is selected, but the useCallback
for handleFileUpload sat below that return. Clearing the selection therefore
changed the hook count and React threw "Rendered fewer hooks than expected",
which takes the whole tree down since nothing catches it.

Move the callback above the bail-out and read the id through place?.id.
2026-08-06 21:59:30 +02:00
Konstantinos Thermos ab9ca4220a fix(atlas): translate unvisited country names via the locale resolver (#1798) 2026-08-06 16:14:02 +02:00
Maurice 143200cae6 chore(dashboard): remove the 4.0.0 release moment (#1792)
The FourZero show was built as a one-off for the 4.0.0 release and was
always meant to come back out afterwards. It lived in its own folder
plus two marked lines on the dashboard, so removing it is exactly that:
the folder and the two lines.

The beacon carried its own cut-off date of 23 August, so nothing about
this changes what users see between now and then beyond taking it out
early.
2026-08-05 19:59:16 +02:00
Konstantinos Thermos dd138e5293 fix(trips): default a new trip to the user's currency setting (#1790)
The new-trip forms hard-coded EUR and ignored settings.default_currency,
so creating a trip always opened on EUR even with USD set in
Settings > General > Currency. Read default_currency (falling back to
EUR when unset) in both the desktop TripFormModal and the mobile
MNewTripSheet; edit mode still pre-fills the trip's own currency.

Fixes #1784
2026-08-05 18:04:40 +02:00
Renzo Beux 4b60cf0c0e Update the mobile sheet tests for the shared location helper
- reverse() now receives the UI language
- error assertions follow the translated journey.editor.* strings
  instead of the raw error.message and the generic common.error label
- a late error callback after a resolved one-shot fix is asserted to be
  a no-op, since a settled promise cannot reject anymore
- add tests for the new location button: reverse-geocoded fill and the
  translated inline denial message
2026-08-05 15:28:20 +02:00
Renzo Beux a66f70031c Wire the current-location button into the mobile entry sheet
- MJourneyEntrySheet renders the same crosshair button in its location
  field, with the coordinate fallback and reverse-geocode race guard
- Quick-capture auto-locate now goes through getCurrentPositionOnce(),
  replacing the raw navigator.geolocation call and the untranslated
  error.message it surfaced
- Error code to i18n key mapping extracted to geoOnceErrorKey(), shared
  by both editors
- Test for the race guard: a search result picked while the reverse
  geocode is in flight must survive
2026-08-05 15:28:20 +02:00
Renzo Beux 3ec4b33dbb Add "use my current location" button to Journey entry editor
- One-shot getCurrentPositionOnce() helper in useGeolocation with typed
  error codes (unsupported, insecure context, permission denied, timeout)
- Button inside the entry editor Location field fills coordinates from the
  device and reverse-geocodes the place name via /api/maps/reverse,
  falling back to formatted coordinates when no name is available
- New journey.editor.* i18n keys in all 23 locales
- Unit tests for the helper and component tests for the button flow
2026-08-05 15:28:20 +02:00
Renzo Beux f718240ffc Let a dayTintProvider send its own colour
Four tones cannot keep a twenty-stop trip's legs apart, which is most of
the point of colour-coding one. A contribution can now carry `#rrggbb`
alongside `tone`, per region or as a shorthand.

The split of control is: the plugin picks the hue, the host picks the
weight. A colour rides the same per-theme, per-region alphas as a tone,
and its OKLCH lightness is clamped into a per-theme band first, so no
contribution can hand a user a day card they can't read. The band
brackets the built-in tones rather than tightening around them, so an
ordinary colour passes through untouched and a hex equal to a tone
renders exactly like that tone. Hue and chroma are never touched - it
bounds a colour, it doesn't boost one, so a near-white grey still lands
as a faint tint.

Only `#rrggbb` survives normalization, and that is a security boundary
rather than tidying: the value ends up inside a `color-mix()` in an
inline `background`, a shorthand that takes a layer list, so a string
closing the paren early could append a `url(...)` and beacon everyone
viewing the trip.

A region gets exactly one paint instruction - what it names beats the
shorthand, a colour beats a tone at the same level - so the client never
has a tie to break. A region naming a colour the host can't use degrades
to `default` like a bogus tone already did.
2026-08-05 15:28:15 +02:00
Renzo Beux c89d2bf3a1 Add a dayTintProvider plugin hook
Lets a plugin colour-code days in the Plan sidebar. trip-segments can
already announce "Kanazawa begins" in the day plan, but it can't make
day 12 look like it belongs to Kanazawa.

dayScheduleProvider can't cover this: it renders a fixed row, and it's
capped at 60 items per provider, so one item per day would tint the
first 60 days of a six-month trip and silently stop. This hook is
bounded by day count instead.

GET /api/day-tints/:tripId returns per-day tones for three regions of a
day card - the number badge (also the mobile day chip), the header row,
and the expanded activity list. `tone` is a shorthand filling every
region a contribution doesn't name; unnamed regions render as they do
today. Tones only, never hex, so the host can pick the alpha per theme
and per region.

One contribution per day, resolved whole: first entry wins within a
provider, first granted provider wins across plugins.
2026-08-05 15:28:15 +02:00
Konstantinos Thermos 17dd6b7caa feat(map): toggle the trip map between default and satellite tiles
Add a corner button on the Leaflet trip map that switches the base layer
between the default street tiles and a tokenless satellite view (ESRI
World Imagery). The choice is a per-user setting (map_base_layer) that
persists and applies instantly, including offline. GL-renderer satellite
styles are unchanged.

Closes #993
2026-08-05 15:13:13 +02:00
Konstantinos Thermos 5315bacc2a fix(costs): save a picked payer when nobody splits the expense
The web expense modal only sent the payer when at least one split
participant was selected, so a personal expense (e.g. a flight paid by
one person, not shared) was stored with an empty payers[] and lost its
payment attribution. Drop the participants.size guard so a picked payer
always goes out, matching MCostSheet. "Nobody paid" stays expressible via
the explicit "No one paid yet" option (payerId 0); the #1286 test is
updated to use it.

Fixes #1766
2026-08-05 15:11:44 +02:00
Konstantinos Thermos 7da4cad086 fix(admin): stop a stale base URL from hijacking the Anthropic endpoint (#1777)
The AI Parsing admin panel kept the Base URL in state after switching provider
and saved it unconditionally, so moving a configured Local/OpenAI provider to
Anthropic left the old host in the stored config and misrouted every Anthropic
request away from api.anthropic.com. Clear baseUrl on save when the provider is
Anthropic, matching the per-user LlmConnectionSection behaviour.

Fixes #1748
2026-08-04 13:18:12 +02:00
Maurice 446bbdc38f fix(client): stop shared items counting towards the recipient bag weight
An item shared through the sharing tier is visible to its recipients but
carried by its owner. The bag totals filtered on bag_id alone, so every
recipient saw the owner's weight added to their own load — which is exactly
the situation that tier exists for (one person brings the first-aid kit).

Filtering by owner_id, as the issue suggests, would have broken more than it
fixed: owner_id is stamped on every item including Common ones, so the group
pool would have shrunk to "only what I entered myself" even on trips where
nobody shares anything. The predicate keeps the Common pool intact and drops
only private items owned by someone else.

Closes #1767
2026-08-03 21:59:34 +02:00
Maurice e25a1276dc feat(client): let people actually type a bag weight limit
The column, the contract, the API route and its tests have all been there
since v2.9.0 — there was simply no field to enter a limit in, so the only
way to record one was to put it in the bag name, where nothing can act on
it. Both the desktop bag card and the mobile bag row now take a limit in
kilograms (how airlines state them) and store it in grams. Emptying the
field clears the limit instead of writing a zero.

While adding it: the fill-bar formula existed in three places, and the one
in the bag modal had been copied without the limit, so a limit set on a
window narrower than xl did nothing. It now lives once in
packingListPanel.helpers as bagFillPct, next to itemWeight.
2026-08-03 21:40:02 +02:00
Maurice 6019b3ed4e feat(client): carry the planned/visited split into the mobile atlas
The mobile screen shares useAtlas, so it inherits the switch state; it gets
its own compact toggle next to the bucket-list button. Search now labels a
planned country as such instead of calling it visited, the stats card hangs
the planned count off the country total, and the suggestion list keeps
places you have actually been at the top.
2026-08-03 20:21:18 +02:00
Maurice 558f348881 feat(client): let the atlas hide countries you have not reached yet
Planned countries are off the map by default and come back through a switch
above the globe, drawn dashed so they never read as somewhere you have been.
The switch only appears once there is something planned to show, and it
remembers its state.

Three things that needed care:

The colour palette is built from the visited countries alone. It assigns
colours by index, so letting planned ones join would reshuffle the whole map
every time the switch is flipped.

countryMap decides both the tooltip and what a click does, so it is built
from the visible countries — a hidden country has to fall through to the
mark-as-visited branch or it becomes unclickable.

The region layer read `data` without listing it as a dependency, which meant
region colours already went stale on their own; adding the switch would have
made that visible.

Five copies of the optimistic mark-as-visited update have moved into
withCountryMarkedVisited, which also promotes a planned country instead of
adding it twice. Antarctica joins the continent row when someone has been.
2026-08-03 20:21:18 +02:00
jubnl 4f8300e2ba fix(client): fall back to scheduled stop times in transit reservations 2026-08-01 12:49:22 +02:00
xthephreakx 57017b8aed fix(navbar): prevent centred tab pill overlapping actions on tablet (#1747)
The desktop navbar's centred tab pill is absolutely positioned, so on the
md-range (tablet, ~768-1024px) its right edge slid underneath the right-hand
action cluster (notification bell + user menu) once the tab text labels made it
wide enough.

Hide the tab text labels below the lg breakpoint (icon-only on tablet, icon +
label on desktop ≥1024px), which keeps the pill narrow enough to clear the
actions. The icons gain title/aria-label so they stay accessible without text.

Co-authored-by: phreaky <phreakydev@gmail.com>
2026-07-29 22:09:40 +02:00
Maurice 129f952db4 fix(pdf): stop a flowing day stranding its header at the foot of a page
Keeping a stay whole moved its cards to the next sheet, and the header bar
stayed behind on the old one — empty, above a stretch of white, with the
same day printed again overhead. The repeat itself is the continuation
marker from #1471; what was wrong is that there was nothing left to
continue from.

break-after on the thead and break-before on the tbody are both ignored by
Chromium here, measured rather than assumed. Holding the whole day together
is what works: a day that fits moves as one, and a day too long for a page
still breaks and still repeats its header. Scoped to the flowing layout,
since a day that starts its own page can never strand one.
2026-07-29 21:32:39 +02:00
Maurice eb817dea65 fix(pdf): keep a stay whole across a page edge
An accommodation could be cut in half by a page break — the check-in time
at the foot of one sheet, the hotel name and address at the head of the
next, under a repeated day header that read like a duplicate day. Place and
note cards have carried break-inside: avoid all along; this one never did.

It could always happen to a day that overflowed its page. Letting the days
flow means content meets a page edge far more often, which is how it turned
up. Printing the same trip through Chromium: two torn stays before, none
after.
2026-07-29 21:32:39 +02:00
Maurice 5dbb2db3ee fix(planner): keep the PDF tooltip inside the window
The export button is the one toolbar button with a hand-rolled tooltip
instead of the shared one. It was anchored with `right: 0` and never
wrapped, so its label grew leftwards out of the day plan — the leftmost
pane — and off the window.

The shared tooltip is portalled and clamped to the viewport, which is what
every neighbouring button already uses. The hover state it needed was
threaded down from DayPlanSidebar and has no other reader, so it goes too.
2026-07-29 21:32:39 +02:00
Maurice be5b48532d fix(pdf): dress the layout switch like the rest of the app
The preview shipped a bare checkbox, which is not what a toggle looks like
anywhere else in TREK. The overlay is imperative DOM, so the switch from
Settings/ToggleSwitch cannot be rendered into it — as static markup it would
carry no behaviour — but its geometry and its tokens can be, and the control
now reads as the same one.
2026-07-29 21:32:39 +02:00
Maurice 03be438e0b feat(pdf): make the page break between days optional
Every day started a new page, so a plan of short days printed one sheet per
handful of lines and looked nothing like the view it was exported from.

The preview now carries the choice, next to Save as PDF, and remembers it.
The two layouts differ by a single class on <body>, so ticking the box
re-lays the open preview out immediately instead of rebuilding the document
and re-fetching every photo.

Breaking per day stays the default — this only adds the other option.
2026-07-29 21:32:39 +02:00
Maurice 397f504d01 fix(planner): reach the day reorder popup with a finger too
The popup is a modal, so it portals out of the sidebar and misses the
opt-in its rows sit inside everywhere else. Its rows already carried a bare
draggable attribute, which on a tablet meant a long press selected the row
label and nothing moved.
2026-07-29 19:15: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 4f99834965 test(admin): stop the plugin sheet test racing its own Escape
MSheet binds the Escape listener in an effect, so a key fired the moment the
sheet content appears can land before the listener exists. Under a loaded
full-suite run that made the test fail on its own timing. It retries until
the sheet is gone rather than pressing once.
2026-07-29 15:14:21 +02:00
Maurice 8b7e2ddaa5 fix(places): evaluate open/closed in the place's timezone (#1680)
The ring was checked against the viewer's clock, so a trip in Europe planned
from Singapore showed the wrong state. It evaluates in the timezone of the
place now, from Google's structured opening periods rather than the localised
description text — parsing display strings breaks as soon as the account
language is not English. Midnight-spanning hours, closed days and 24/7 are
covered.
2026-07-29 15:14:21 +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 f013c4964c chore(client): set every coverage threshold to 85
Treats the gate as a floor rather than a target — the suite runs well above it,
and a PR that legitimately trades a few points should not have to move the
numbers to land.
2026-07-29 12:56:17 +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 a4bf815d24 test(import): read the upload body instead of parsing it as FormData
These are the only two test files that call request.formData() inside an MSW
handler, and they hold exactly the three tests that failed on CI while passing
locally. The body axios' XHR adapter produces under jsdom is one Node 24's
multipart parser rejects, so the handler threw before reading a field and the
component reported a failed upload. Reading the raw payload asserts the same
thing without depending on the parser.
2026-07-29 11:55:31 +02:00
Maurice 1d9fe9a3df test(import): stop the upload fixtures lying about their size
The helpers built a one-byte File and then redefined size to 100, so the
multipart body and the length it announced disagreed. Node 24 rejects that
while 22 lets it through, which is why only CI saw it: every test that
uploaded such a file and expected success failed, the one expecting an error
passed for the wrong reason, and the KML case with a truthful File was fine.
2026-07-29 11:55:31 +02:00
Maurice 16922766e5 test: give async assertions a realistic budget on CI
waitFor and findBy* default to 1s. That holds on a dev machine but not on a
4-core runner executing 527 test files in parallel forks, where three import
dialog tests timed out waiting for their upload to settle.
2026-07-29 11:55:31 +02:00
Maurice 99816bff4c test(costs): escape the BOM in the CSV export assertion
A literal U+FEFF inside a regex trips no-irregular-whitespace, which is an
error rather than a warning, so the client lint job failed. The escape is
equivalent.
2026-07-29 11:55:31 +02:00
Maurice 02ad6c3d16 test: close the remaining gaps and raise the thresholds
Pushes lines from 95.2% to 98.1% and branches from 85.2% to 89.9%, mostly
in the release-notes modal, the reservation overlay, the journey entry sheet
and the trip dialogs.
2026-07-29 11:55:31 +02:00
Maurice b80bd3992b test: pick up the rest 2026-07-29 11:55:31 +02:00