Commit Graph

1835 Commits

Author SHA1 Message Date
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 e606ebd9b9 chore(server): pin the WebSocket envelope, dedupe the rate limiter (#1803)
* chore(nest): register LlmParseModule in AppModule

The module was only in the container because BookingImportModule and
PluginsModule happen to import it, so /api/admin/llm/local depended on those two
keeping that import. Register it where every other module is registered.

* refactor(nest): give the rate limiter one home and one instance

RateLimitService keeps its counters in a Map on the instance, but it was listed
in the providers of auth, oauth, transit and trip-invite, so each of them got its
own. Nothing is broken today — the bucket names in use (login/mfa/forgot/reset,
oauth_*, transit_*, trip_invite) don't overlap — but two modules picking the same
name would have quietly allowed twice the configured attempts.

Move it to nest/common, where it belongs now that four domains use it, and hand
it out through a small module they import. Not @Global: the e2e suites build a
container around one domain module, so a global AppModule never pulled in isn't
there at all.

transit.mcp.ts keeps its own instance on purpose — it keys by userId under
mcp_transit_* while the REST path keys by IP.

* test(ws): pin the wire envelope before touching the transport

Every frame on the socket is flat: {type, tripId, ...payload} out, {type, tripId}
in. Nest's WsAdapter dispatches on message.event and answers {event, data}, so
moving this onto a gateway without a custom serialiser would leave join silently
unhandled — no welcome.socketId to send back as X-Socket-Id, and every client
would then receive its own writes.

Nothing catches that today: the 100+ tests that touch realtime mock src/websocket
and never look at the bytes. WS-032 sends the exact shape the stock adapter would
and asserts the server ignores it.

* docs(nest): drop the parts of the README that no longer exist

strangler.ts, DEFAULT_NEST_PREFIXES/NEST_PREFIXES and tests/parity/ are all gone —
every request goes through Nest now — but the README still walks a new contributor
through adding a prefix and writing a parity test against the Express route.

Also spell out that the 80% gate is an average over src/nest/**, not a floor per
file, so a large untested module can sit behind the well-covered small ones.
2026-08-06 22:44:59 +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
dependabot[bot] c08530f928 chore(deps): bump undici from 7.28.0 to 7.29.0 (#1780)
Fixes CVE-2026-13697 (HIGH).
2026-08-05 15:34:28 +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 8a59c139c0 Sync the runtime SDK types with colours and note mobile tints only the badge 2026-08-05 15:28:15 +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 28ffc814c0 fix(llm): retry OpenAI import with max_completion_tokens
Newer OpenAI models (gpt-5.x) reject max_tokens with a 400 and demand
max_completion_tokens, so AI booking import failed and no reservations
were created. Send max_tokens first (understood by the classic chat API
and every local server), and only on a 400 naming max_completion_tokens
resend the whole request with that parameter instead — mirroring the
existing json_schema->json_object fallback beside it. Detection is by
API response, not a model-name allowlist.

Fixes #1760
2026-08-05 15:13:17 +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 d7d7fac339 feat(i18n): translate the bag weight limit strings 2026-08-03 21:40:02 +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 628f4e78cd fix(server): let a copied packing item keep its weight
createItem never accepted weight_grams or bag_id — not in its signature, not
in the INSERT — so cloneItem had nothing to pass on and every copy arrived
empty. For a group trip that meant re-entering every weight by hand for each
traveller, which is what people were doing.

The bag needs more care than the weight. Bags belong to a trip, but they can
be assigned to a person, so inheriting the original bag would drop your copy
into somebody else luggage and inflate their total. A copy keeps the bag only
when nobody owns it or when the copier is one of its members.
2026-08-03 21:40:02 +02:00
Maurice 4e090411fe fix(server): stop a failed notification import from crashing the run
The fire-and-forget notification sends guard the send() call but not the
dynamic import around it, so a rejected import escapes as an unhandled
rejection. In CI that surfaced as an EnvironmentTeardownError from vacay
after the suite had already passed, failing the run with every test green.

trips, packing and collections already catch on the import chain; collab and
the two vacay sends were missing it.
2026-08-03 20:21:18 +02:00
Maurice 8ab50dcbbb docs(wiki): explain visited versus planned countries in the atlas
The page still described the old behaviour, where every trip counted as a
visit regardless of its dates.
2026-08-03 20:21:18 +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
Maurice 97f6a43333 feat(i18n): translate the planned-countries strings
Also adds atlas.antarctica, which CONTINENT_MAP has always known about but
no locale ever had a word for.
2026-08-03 20:21:18 +02:00
Maurice d9778ed7c0 fix(server): keep the passport card in step with the atlas
getTravelStats derives its countries independently of the atlas service, so
without the same date filter the dashboard would have kept counting trips
the map no longer treats as visited — the drift #1490 closed.

Manually marked countries and the hidden-country tombstones are untouched.
2026-08-03 20:21:18 +02:00
Maurice 269bd9140f feat(server): count only started trips as visited in the atlas
Every country now carries the status of the strongest source it came from:
a country reached by both a past and a future trip is visited, one that only
appears in upcoming trips is planned. Marking a country by hand still wins
over everything, since that is a statement of fact rather than a date.

totalCountries drops to the visited ones and totalCountriesPlanned sits
beside it; continents and mostVisited follow the same rule. countries[]
stays a superset of what it returned before, so an older client keeps
painting the map exactly as it did.

The endpoint query has to select trip_id to know which trip a leg belongs
to, which makes its DISTINCT per trip — the coordinates are collapsed again
in JS so the point-in-polygon lookup still runs once per location.
2026-08-03 20:21:18 +02:00
Maurice ddba45ab3e feat(shared): tell visited countries apart from planned ones
The atlas counted every trip as a visit, so a country you had merely booked
a flight to was stamped the same as one you came back from.

tripVisitStatus buckets a trip by its dates: already started (or running)
counts as visited, starting in the future is planned, and a trip with no
dates at all is an idea that must not reach the visit stats. The comparison
is UTC, matching the dashboard and journey lifecycles.

Requested in discussion #1048.
2026-08-03 20:21:18 +02:00
jubnl c42aea4171 Merge remote-tracking branch 'origin/dev' into dev 2026-08-02 15:52:22 +02:00
jubnl 414d967223 docs: regenerate migration-graph after the admin fold
Records the fold and corrects two long-carried errors: there is no admin MCP
surface (the '11 MCP consumers' figure predated the Phase-0 addons extraction)
and systemNotices/conditions.ts was never a consumer. The memories/admin edge is
documented in the right direction for the first time. journeyService and
oauthService now head the frontier.
2026-08-02 15:08:02 +02:00
jubnl 2ac6c76086 chore: move admin backend to proper DI
The 851-line services/adminService folds into the wrapper AdminService over
DatabaseService plus injected Settings/Addons/Passkey/Packing/Auth/Permissions/
Notifications services; the auth, notifications and permissions bridge imports
all become injections, PERMISSION_ACTIONS stays a plain const, and the
mcp/sessionManager deep import keeps its anti-cycle comment. Ahead of the fold
the 11 packing-template functions move to PackingService, which already owned all
three template tables — resolving the admin-2 residual with no bridge. The pure
and module-scoped half moves to admin.helpers.ts, the version cache staying
module-scoped so admin.bridge's instance and the container singleton share one
GitHub fetch. A 1-export admin.bridge.ts serves scheduler.ts's cron, the only
out-of-container consumer. Recipe steps 3 and 4 are no-ops: no src/mcp/tools/
admin.ts has ever existed and the plugin host never imported the domain.

Four lines are non-verbatim, all path re-anchoring one directory deeper, both
resolved paths verified against the emitted dist layout.

admin.dto.ts clears all twenty AdminController allow-list entries, retiring the
three 'enabled must be a boolean' checks plus 'permissions object required' and
'Object body required' for the pipe envelope. The module e2e goes DI-native: the
3-method whole-module mock dies, 6 cases become 15 over real SQL.

Quirk fixes on top, each pinned: the three places toggles go fail-closed with an
append-only backfill migration; the template item routes honour :templateId;
updateUser rejects an empty username/email; createInvite 404s an unresolvable
trip_id; listOAuthSessions guards its scopes parse; GitHub fetches gain a timeout
and size cap and failures cache briefly; updateOidcSettings/updateAddon/updateUser
become transactional; admin.oauth_session.revoke is renamed to match its
siblings; two mutations gain their missing audit rows; getAuditLog drops the
_parse_error sentinel.

Tests move with IDs preserved (ADMIN-SVC-001..069 incl. the pre-existing 029/030
gap and duplicated 069, VNOTIF-001..007), plus ADMIN-BR-001 and ADMIN-SVC-070..076.
2026-08-02 15:07:55 +02:00
jubnl ee13b6325b feat(shared): extend the admin request contracts
Twelve schemas covering every AdminController body, replacing the four that were
imported nowhere. Deliberately permissive wherever AdminService already owns a
bespoke 400 ('Invalid role', 'Name is required', 'Username, email and password
are required'), so those errors survive byte-identically: role is z.string() not
an enum, template name and the user-create fields stay optional. savePermissions
keeps z.unknown() values so unknown levels still land in the 200 response's
skipped list; addon config and the notification-preference matrix stay records so
per-addon keys and plugin channels are not stripped; devTestNotification's inApp
accepts the object form the dev panel actually sends.
2026-08-02 15:07:23 +02:00
Johann Bauer ad7d81fd00 fix(i18n): use mode-agnostic German departure translation (#1765)
'Abflug'/'Abflugzeit' are flight-specific, but the reservations.departureDate/
departureTime keys are shared across all transport types in TransportModal
(flight and train waypoints). Switched to 'Abreise'/'Abreisezeit', which read
naturally for any mode of transport.
2026-08-02 14:44:15 +02:00
jubnl aec2d8cd87 docs: regenerate migration-graph after the notifications fold
Import-scan regeneration: notificationService and the folded
inAppNotifications leave the graph, the notifications cluster is consumed as
plain infra by the DI service, and the unblock cascade over-delivered —
repointing memories' sends erased that cluster's last domain edge, so BOTH
adminService and journeyService join the ready frontier (adminService heads
the order). Adds the dated correction bullet and the 'Quirks fixed after the
notifications fold' section (fixed vs deliberately-preserved).
2026-08-02 11:36:46 +02:00
jubnl 00447c9043 feat(server): validate notification request bodies
notifications.dto.ts wraps the @trek/shared request schemas via createZodDto
and types all five @Body() params; the five NotificationsController entries
leave the body-contract allow-list (ratchet gate enforced). Contract catch:
the client sends server/token: null on test-ntfy to mean 'use the saved
value', so testNtfyRequestSchema gains .nullable() there (spec pinned). The
inline response-enum and url-type checks die — malformed bodies now get the
pipe's standard envelope; valid bodies behave byte-identically. Two
integration cases pinning pre-ratchet tolerances (flat notify_* preferences
body, body-less test-smtp POST) update to the current wire contract.
2026-08-02 11:36:36 +02:00
jubnl d203d8cccd chore: move notifications backend to proper DI
The send() dispatcher (services/notificationService.ts) and the in-app store
(services/inAppNotifications.ts) fold together into the DI-native
NotificationsService over DatabaseService + RealtimeService; the prefs
matrix, transports, channel registry and inAppNotificationActions stay plain
infra modules. A 1-export notifications.bridge covers scheduler, legacy
adminService/memories and the six lazy fire-and-forget sends; AdminController
and the plugin RPC host (24th factory dep) inject. The 5-tool registrar +
notifications-in-app resource move to notifications.mcp.ts. Tests move with
case IDs preserved (NSVC-*, INOTIF-*, + NSVC-020 bridge pin); ~18 suites
repoint path mocks/warm-ups; the module e2e goes DI-native.

Also carries the verified-quirk fixes that would normally trail as a
separate fix(server) commit (file-overlap with the fold): toUtcIso on all
three created_at paths (INOTIF-013/014), respond claims the response CAS
before running the action handler with release-on-failure (INOTIF-011/012),
synology's fire-and-forget send gains .catch, the dispatch-failure log
unwraps Error messages (NSVC-021), and the MCP resource description drops
its false 'unread first' claim.
2026-08-02 11:36:06 +02:00
jubnl c814653f4e fix(server): tidy passkey quirks after the DI fold 2026-08-02 10:28:22 +02:00
jubnl 74be789f22 docs: regenerate migration-graph after the passkey fold 2026-08-02 10:20:38 +02:00
jubnl 5d4b2abd26 feat(server): validate passkey request bodies 2026-08-02 10:16:10 +02:00