Commit Graph

528 Commits

Author SHA1 Message Date
Maurice 7d0e90eb47 Make the plugin permission facts live in one place
The hook->permission map existed verbatim twice (the host supervisor and the
plugin SDK) and the permission list four times (envelope, SDK manifest, and
both admin panels in the client). All of them were in sync when measured -
63 entries each, 15 hook entries each - so this changes no behaviour. It
removes a quiet outage: a hook whose permission is added on only one side
fails silently in production, with the plugin installing, activating and then
simply never being called.

protocol/envelope.ts is the single source now. METHOD_PERMISSION was typed
Record<KnownMethod, string>, so its value side was unconstrained -
'db:read:trps' compiled and made the method unreachable forever. It is
`as const satisfies Record<KnownMethod, KnownPermission>` now, which catches a
typo'd value, a missing key and an extra key. The derived KnownPermission and
MethodPermission<M> are what the @PluginMethod decorator will check its
argument against.

Deliberately no explicit annotation on METHOD_PERMISSION: with one, typeof is
that Record, MethodPermission<M> widens to the whole union, and the check
silently evaporates.

server/scripts/gen-plugin-facts.ts imports envelope.ts and writes the SDK and
shared copies. It does not scrape. The guard it replaces pulled the block out
of plugin-supervisor.ts with a regex that goes null on a reformat, and it ran
only from prepublishOnly - drift would have surfaced as a broken npm release.

The generated files are checked in because publish-plugin-sdk builds from a
standalone checkout with no server present.

The SDK's re-exported types stay wide. plugin-sdk compiles under strict, and
grantGaps indexes HOOK_PERMISSION with a plain string, so an `as const` there
is TS7053 and would break the publish. Strictness belongs on the host side.

--check also fails on a permission with no admin.plugins.perm.* translation
and on one the CLI has no hint for. Both are silent today.

plugin-sdk/** was in no workflow path filter, so nothing in CI ever ran its
tests. It is in the filter now, alongside a job that runs the drift check.
2026-08-09 00:52:05 +02:00
Maurice 6bf2e0708d feat(client): add the Startup section to General settings
Desktop and mobile both get it, in the style of the file they live in -
the On/Off button pairs on desktop, segments and a picker sheet on mobile.
The tab picker only appears once "Active trip" is chosen, since there is
nothing for it to mean otherwise.

Labels come from the same keys the planner's tab bar uses, so the picker
can never name a tab differently from the tab itself.
2026-08-08 23:01:40 +02:00
Maurice 1e05501b9d refactor(client): finish the React 19 migration (#1826)
Mechanical, and it ends the migration visibly.

45 files imported the whole react-dom default export to reach one function.
They now import createPortal by name, across 54 call sites; six files already
did it this way. main.tsx keeps its react-dom/client import, which is a
different module and already correct.

forwardRef is gone from every component. In React 19 ref is an ordinary prop,
so the wrapper only obscured the signature:

  NumericInput   — a plain DOM ref, no imperative handle involved
  JourneyMap     — useImperativeHandle stays, only the wrapping goes
  JourneyMapGL   — same
  JourneyMapAuto — same

Each takes `ref?: Ref<Handle>` in its own Props interface now, so the ref is
part of the declared surface instead of arriving through a generic parameter.

The two journey bindings in glLazy shrink to one line each. They existed as
forwardRef wrappers purely to carry the ref past the lazy boundary; with ref an
ordinary prop it rides through the spread like everything else, which makes all
six bindings in that file identical again.

The five React.FC annotations go. Three were `Record<string, React.FC<{size?:
number}>>` for provider icons in files that never import React at all — those
only typechecked through the UMD global — and are now ComponentType. The two
pages become function declarations.

TranslationContext uses the 19 context shorthand, which leaves its React default
import unused, so that goes too.

Also drops two dead vi.mocks of react-dom/server. Their comment still said "used
in createMarkerIcon", which stopped being true when marker icons moved to the
in-house serializer — nothing under src/ imports react-dom/server any more
except the parity test that exists to compare against it.
2026-08-07 17:03:47 +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 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 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
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 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
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 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 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 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 bbb0f93af2 test(components): cover map, collections, settings, journey and admin 2026-07-29 11:55:31 +02:00
Maurice a3b263017f test(planner): cover the day plan sidebar and the planner dialogs 2026-07-29 11:55:31 +02:00
Maurice c071901e55 test(4-0): cover the KEINE ANGST show 2026-07-29 11:55:31 +02:00
Maurice 3c575a06f4 Add the track colour picker to the place inspector
New shared component rather than a ninth copy of the swatch grid that is
already inline in eight places. Selection is drawn with var(--accent) so
it follows whatever accent the user runs.

The custom-colour cell listens for the native change event instead of
React's onChange: for a colour input React maps onChange to input, which
fires on every drag inside the OS picker — dozens of writes, each one a
409 candidate against the base-version header. The auto cell previews the
colour you would get back, not the one currently set, which is a
different value as soon as anything is picked.

The row sits next to the track stats block, not inside it: that block
returns null on unparsable geometry and the colour control has no
business disappearing with it. The elevation chart and the distance pin
follow the track colour too, otherwise the panel and the map read as two
unrelated objects.

In the places list the track icon becomes a stroke in the colour the line
is drawn in. The map has no legend of its own, so that stroke is the only
thing tying a line back to a row. Undo after deleting a track now
restores its geometry and colour instead of bringing back a bare point.
2026-07-28 07:25:36 +02:00
Maurice fcabb4c645 Draw tracks in their own colour on both map engines
The MapLibre layer was already data-driven, so it only needed the feature
property filled. Leaflet needed more work: the GPX polyline passed its
style as bare color/weight props, and react-leaflet only calls setStyle
when the pathOptions reference changes — a recoloured track would have
kept its mount-time colour until the next reload. Every other vector
layer in here already uses pathOptions.

A track with a colour gets a white casing so it survives satellite
imagery and dark basemaps. The casings live in their own pane below the
lines: Leaflet stacks paths in insertion order, so drawn inline they end
up over the line of a track that was added earlier. They are always
mounted and hidden by opacity, otherwise giving a track a colour remounts
the path into the wrong position.

Tracks are clickable now — 3.5px is not a target, so the hit area is a
transparent 14px line on top. bubblingMouseEvents is off on it: paths
bubble to the map by default (markers do not), and the map's own click
handler would clear the selection the track just made. The GL side needs
the mirror image of that, a guard so a click on a cluster bubble over a
track still belongs to the cluster.
2026-07-28 07:25:36 +02:00
jubnl a8d6663dc7 fix(client): send multiple_choice from the desktop poll form 2026-07-27 00:27:12 +02:00
jubnl 2fa075a076 fix(shared): align todo contracts with the wire format the client sends
The optional metadata fields (category, due_date, description,
assigned_user_id — plus priority on update) now accept explicit null: the
client clears fields by sending null, which the schemas rejected. checked
accepts the legacy 0/1 numeric form alongside boolean (the integration net
pins it). Drops the client-side casts that papered over the too-narrow
types.
2026-07-26 14:54:53 +02:00
Maurice c8369bd14b fix(plugin): clear brokered session state on logout
sessionStorage outlives a logout inside the same tab, so the keys stay readable
after the next user signs in. They are namespaced by user id, which stops the new
user's plugins from reading them, but the data is still sitting in the tab — the
same reason logout already drops the appearance snapshot, the SW caches and the
user-scoped offline DB. Purge them there too.

Three things around it, from reading the handlers next to the new ones:

- The inactive-plugin purge ran before the store was updated, so a sessionStorage
  failure cost us the plugin list we had just fetched. Commit first, purge after.
- The session handlers answered without checking requestId, unlike confirm and
  geolocation — an answer the frame can never match is worth skipping.
- The mock host threw plain Errors while the real bridge rejects with .code set,
  and checked the key before the trip scope where the host does the reverse, so a
  plugin's error handling could pass its tests and still break in the app.
2026-07-26 11:48:15 +02:00
geracobo 2068133de2 feat(plugins): add quota-limited session storage API 2026-07-26 11:48:15 +02:00
geracobo cc8e4d480e fix(plugins): corrected non-sequential test numbers for PluginFrame 2026-07-26 11:48:15 +02:00
geracobo 9d8d0b1653 feat(plugins): add session storage api for plugin bridge 2026-07-26 11:48:15 +02:00