The single-via route is right for a detour dragged by hand and wrong for
anything that derives its anchors from a line. Pinning a day onto a recorded
track or a signed scenic road means a couple of dozen points, and one request
each re-routes the entire trip once per point, spaced by the routing host's own
rate limit — twenty anchors would spend half a minute drawing routes nobody
asked to see.
replace_legs names the legs to clear rather than taking a flag for the day, and
that is the whole difference: a traveller who bent two legs by hand and then
adopts a scenic road for a third has not asked to lose the two.
The tools follow in the same change, as parity requires. Undoing a detour picks
this up too, where the vias of one leg were deleted one request at a time.
The road-trip addon's second half: the parts that let a planned drive be
argued with rather than only read.
Reshaping. A via records which stop it follows by position, and that position
is resolved fresh every time the day is routed — so inserting, reordering or
removing a stop moved the ground under every via after it, and the drive
silently snapped back to the road it had been steered away from while the
handle still sat on the map where it was left. The correction is computed
against the day as it stands and written with the same change, in one
transaction: a half-applied re-anchoring mixes two numbering schemes in one
day. The one judgement that needs geometry — which half of a split leg a via
belongs to when a stop lands in the middle of it — is measured on the road
actually driven, so a fuel stop added to a bent leg keeps the bend on the side
it belongs to.
Alternatives. OSRM answers most long legs with exactly one route, which read as
"there is no other way" when a perfectly good slower road existed. When it
offers nothing, the same drive is asked for again without the motorway, and
failing that without the tolls — one question at a time, stopping as soon as
one lands. The offer says what it is rather than how much slower.
Stops along the way. Everything the corridor search can find is a pause, not a
destination: fuel, charging, rest areas, campsites, food and sights all sit
inside the leg they were found on, carry their own colour and icon, and are
left out of every stop count. That colour is defined once and read by the rail,
the corridor list, the map pin and the picker — and it replaced the chains'
logos, which turned a corridor full of petrol stations into a row of
advertisements and left the brands with no logo on file looking like something
else entirely.
How long a stop takes was readable in the rail and writable nowhere; new places
were given an invented hour that nobody had entered. It is now set from the
rail, cleared as a zero because the update statement folds a null into "leave
it alone", and new places start with nothing.
Both columns are rebuilt on one type scale, anchored to the day plan's own
sizes so switching tabs does not change the size of the text beside it. The
rail reads as trip totals, day cards and a chain of stops with the drives
between them; the corridor column as what to look for, how the search is
getting on, and what it found. Six category pills over three lines became one
dropdown, which is the space the results wanted.
Also: the offered routes are framed on the map when the picker opens (`fitKey`
carries no coordinates and each renderer reads it as "the selected day"), road
trip mode does not cluster at all so the whole drive stays readable zoomed out,
and the drive-time labels finally render in the app's own typeface instead of
Leaflet's Helvetica. All of it in both renderers.
A petrol station and a campsite both came back as the same nameless grey dot, because
the road categories only existed on the server: the map falls back to grey for a
category it does not know. They now sit in the client's category table too — with the
pump, the bolt, the parking sign and the tent, in their own colours — which both map
renderers and the popup read from the same place.
They stay out of the explore pill's own list on purpose: that pill offers what a city
visit needs, and while road trip mode is on it is not on screen anyway.
The search also stayed quiet when only some boxes failed, which reads as "no fuel on
this stretch" when the truth is that nobody looked. A partial failure now says how many
stretches went unanswered.
The corridor was built from the day's stops, so it followed straight lines across
country the car never drives. Between two cities that line runs through open fields
while the motorway swings wide of it, which is why every hit came back sitting on a
stop rather than on the way.
The days now carry the routed geometry, and the corridor is cut from that. Same trip,
same search: 192 hits spread over the whole 482 km — first fifty, middle, and the last
stretch — where before all of them reported kilometre zero.
Following real roads takes more boxes to cover the same drive, so they go out three at
a time instead of strictly one after another, and the budget rises from ten to sixteen.
The panel head loses the sentence explaining what the sections below already say, and
the day's distance and driving time, which the day list has been showing all along.
Three things the first cut got wrong.
Switching into road trip mode took its time: every leg was its own routing request,
paced a second apart so the shared hosts would answer at all. The router already
returns a leg per consecutive pair of the waypoints it is handed, so a day is now one
request — split only where the travel mode changes, because one request carries one
mode. Answers that come from the cache are no longer paced at all, which is what made
returning to the view feel broken.
A list of petrol stations is half an answer: which one is on your side of the road is
the other half. The corridor search therefore moved out of the panel into the planner,
and its hits are handed to the map like any other place suggestion — grey, because they
are not part of the trip until you add one. The explore pill stands down while the mode
is on: it draws into the same channel and answers the same question for a drive.
The panel itself was a stack of controls without an order. It now reads as header,
search, results: what is being searched (with the day's distance and driving time),
what is being looked for, how far off the route, then the hits grouped by kind with a
count — and a progress bar while the boxes are being worked through.
The road trip mode could show the route but not what sits beside it, which is
half of what planning a drive is about (#1797).
The right column becomes a corridor search: pick what you need, pick how far off
the route you will go for it, and the day's drive is searched box by box. The
boxes come from the route itself rather than the viewport, so the answer is
"on the way", not "on screen"; each hit reports how far off the route it lies
and how far into the drive it comes, and adding one opens the normal place form
so it lands on the day like any other stop.
Overpass is asked in plain bounding boxes rather than with a polyline filter,
because that is what the existing /api/maps/pois route accepts and what the
mirrors can answer; the geometry work is pure and tested. Long routes stop at ten
boxes and say so instead of quietly searching half the drive.
Fuel, charging, rest areas and campsites join the server's POI categories, so the
explore pill and the MCP search_pois tool gain them too. A car booking's stops now
also reach the PDF, which printed pick-up and return with everything in between
missing.
The trip planner could show one day's route at a time and nothing across days,
so a road trip had to be pieced together in Google Maps (#1797, #435).
The addon adds a mode to the plan view rather than a tab of its own: the map
stays the workspace and gains the route over the whole trip, while the left rail
becomes one chain of stops with the driving distance and time between them and
arrival times that follow from them. A time pinned on a stop is kept and restarts
the chain, so a ferry or a museum ticket stays where the user put it.
Car bookings can finally carry the stops the schema has always allowed: the
endpoints table has held role/sequence for every transport type since #384, but
only flights and trains had an editor for them. The MCP tools already accepted
them, so this closes the gap the other way round.
Legs are fetched one at a time and spaced out — the public routing hosts answer a
whole trip's worth of requests with 429 — and the distance cap now looks at the
single leg instead of the sum, which is what dropped long drives entirely.
trek_places_enabled was read by six call paths — search, autocomplete, the POI
pills, booking-import geocoding, the offline area and details resolution — and
written by nothing. No admin route, no MCP tool, no switch anywhere in the UI,
and the key is not in the settings allow-lists either, so the only way to set it
was an INSERT against app_settings by hand on the server.
That matters more than the four switches beside it, which trade away quota. This
one decides whether every search and every keystroke in the place box leaves the
instance at all. The flag reads fail-open, so an install that upgrades without
visiting the admin panel starts sending them, which is the right default and
exactly why the operator needs somewhere to say no.
Adds the pair symmetrically to place-shadow from the same branch: getTrekPlaces
/updateTrekPlaces on AddonsService, GET/PUT /api/admin/trek-places with an audit
row, and a real switch on the card. The read stays fail-open so it keeps
agreeing with MapsService.trekPlacesEnabled(), and a test pins that agreement
across every stored value — if those two ever diverge the panel shows "off"
while the queries keep leaving, which is worse than either default.
The card said this itself: `available` was a display-only prop whose comment
read "False until the client is wired to the service; the card then says so
instead of offering a switch that would do nothing." It is wired now, so the
prop is a switch. Its two badge strings had been shipped to all 23 locales and
rendered nowhere; they make way for the switch label and a line naming what
answers while the index is off.
Overture has no descriptions, and the encyclopaedias only describe the
places that have an article, which is a fraction of a percent. What
Overture does have is the address of the page the description is on, for
43 percent of places, and those pages publish a summary in JSON-LD or
og:description precisely so machines can read it.
The API fetches each page once and keeps the summary; every instance
reads it from there. TREK never opens a business's website itself, which
would mean one request per instance per place against small servers.
In the ladder it sits below the encyclopaedias and above Google: an
article about this exact place beats the operator's own copy, but for a
restaurant or a shop the operator's summary is the only one that exists.
Credited by host and linked back, with no licence claimed.
The corpus behind "is our own index good enough to replace Nominatim".
When somebody searches and then picks a result, the client posts the query,
the rank and the pick. Nothing else: no user, no trip, no session, and
coordinates only to three decimals.
Deliberately decoupled from the search endpoint. Correlating a response
with a later pick would mean handing out a ticket on POST /api/maps/search
and threading it through all fifteen call sites; the client already holds
everything the row needs at the moment of the pick.
Evaluation happens offline afterwards: export the corpus, run it against a
candidate index, count how often the pick came back in the top five. That
is why collection can start before such an index exists, which is the whole
point, because the corpus needs weeks of real use and the index needs a
server, and those two waits should not be serial.
Off unless an admin switches it on, and it reads === 'true' rather than
!== 'false'. The three older places switches fail open because a migration
backfilled a row for installs already using them; nothing uses this one, so
an instance that upgrades and never visits the admin panel logs nothing.
Reading is admin only, retention is 180 days, and the switch state is on the
unauthenticated app-config endpoint on purpose: whether an instance records
what its users search for is something a visitor may know before logging in.
The card was a flat list: two key fields, four Google switches and a large
weather panel, all at the same level. That gave a switch you set once the
same weight as the key it depends on, and gave the loudest treatment on the
page to the one thing with nothing to configure.
Now three comparable provider blocks. The TREK index carries the accent and
the recommendation, because a key should be the exception rather than the
expected setup. Google carries no border badge, so it does not stand out,
and says plainly what it costs beyond money. Weather has left the card: it
needs no key and had nothing to set.
The "recommended" badge moved from Google to the index, which is the whole
point of the change.
Also here, and both are their own thing:
- The settings tab is two columns from xl up. Nearly every card is "label
left, switch right", so the middle stayed empty while the page scrolled
for ages. Explicit columns rather than a grid over the flat list, because
a grid pairs cards row by row and leaves a hole under the shorter one,
and these differ by hundreds of pixels.
- Pictures: the nearby fallback drops from 300m to 60m and is skipped
entirely for shops and gastronomy. Measured across 600 places in six
cities, 2 percent of ordinary businesses have a picture on Wikimedia
against 70 percent of churches, so for a cafe the curated rungs never
fire and the fallback always does. That is how the town hall ended up
over the doner shop. It fails open on an unknown category, so landmarks
keep the fallback that works for them.
vite.config reads the dev port and the API target from the environment,
defaulting to what they were. Several worktrees could not run side by side.
Transitous has no GTFS coverage for much of Asia, so a search between two
Osaka subway stops finds nothing — the stops are not in the feed at all.
Add Google as an optional backend behind an admin setting, reusing the
install's existing Places key, and fall back to Transitous whenever no
key resolves.
Uses the Routes API rather than the legacy Directions endpoint the
request named: every other Google call here already speaks the
v1/X-Goog-Api-Key family, and Google closed the legacy endpoint to Cloud
projects created after March 2025.
Google returns walking as turn-by-turn navigation steps where MOTIS
returns one leg per walking segment, so consecutive walk steps are
coalesced into a single leg — otherwise a finely-sliced walk pushed a
real journey past the 20-leg cap and the whole itinerary was dropped.
Both responses now name the backend that answered. The fallback is
silent by design, which left an empty result ambiguous between "no data
here" and "the provider you picked never ran", and the admin panel warns
when Google is selected but no key resolves — or when only the admin's
own key is set, which serves them and nobody else.
Cost shaped the implementation, since Google bills where Transitous does
not: both field masks are cut to the cheapest SKU tier that still
answers the question, cache TTLs are longer than the Transitous path's,
and route alternatives ride along inside the already-billed request.
Addresses #1699
Five findings from auditing the release branch, none of them a blocker for it,
all of them things it would have shipped with.
The mirrored booking price only survived an edit when the form happened to fill
metadata of its own. Both reservation forms and the phone sheet sent
`metadata: null` whenever the form had no metadata key to send, and null clears
the column outright — so restaurant, event, tour, parking, other, a hotel with
no check-in times and a transport with no carrier details all lost the price
again on the next save. They send the object now: it still clears what the form
dropped, and the server carries the price across (#2233).
Your share counted expenses nobody has paid for, while the balances right below
it stopped counting them. The tile contradicted the panel it sits in (#2225).
App.tsx kept its own list of routes that must not mount authed-only widgets, and
it had already drifted from the one the response interceptor uses: this one
named /public/journey/, that one covers all of /public/. It reads
isAuthPublicPath now, so a route cannot be public to one and not the other.
LLM_TIMEOUT_MS and TREK_PLUGINS_IGNORE_TREK_RANGE shipped in .env.example but
not in the compose file most installs actually read.
And a typo in the Russian offline copy: Завершёна → Завершена.
The phone map filters down to one day and had no way back out. The chip
rail can only swap one day for another, the second tap on the active chip
is the day sheet, and the seed guard deliberately does not re-seed — so a
day picked on the map stayed picked until you left the trip and came back
(#2257). Desktop has had the deselect since the day header got it.
Its own round control beside the rail, map view only: the plan timeline is
single-day, so there is nothing to widen there, and the day sheet keeps the
one route into it that also exists on the map. It toggles — the first press
parks the day it came from and frames the whole trip, the second puts that
day back with its route, or falls back to the focus day if a remote delete
retired it in between. Leaving the map restores it too, since an empty
timeline would be the worse answer.
Four separate defects sat behind "offline mode is broken".
The app shell. On the first online launch after a version bump, App.tsx
deleted EVERY Cache Storage bucket and unregistered EVERY service worker,
then reloaded. That is exactly wrong for a Workbox autoUpdate build, which
swaps its precache atomically on install and never leaves an empty one.
Between the purge and the completion of a fresh 22 MB precache the origin
had no shell at all; anyone who closed the app or lost signal inside that
window could no longer start the PWA offline. It also threw away the map
tiles and documents the user had downloaded on purpose. It now asks the
worker to fetch the new build and reloads once it has taken over. The
version marker also moves inside the guarded path, because it was written
after the purge and outside its try: a throwing setItem left the caches
gone, the marker unwritten and the reload unreached, repeating on every
launch.
The reported version is validated before it reaches this device's storage and
before it is compared against the stored marker, so a value that is not a
release tag can neither be persisted nor start an update on every launch.
The session. loadUser's offline branch kept the persisted session but
never called onAuthSuccess, the only caller of setAuthed(true), and the
mutation queue's flush, syncAll and prepareForOffline are all gated on it.
A session that launched without network could therefore never sync again
for the rest of its life, even after the signal returned: the offline
settings buttons spun and returned instantly having done nothing, and
queued edits never uploaded. It also left reads pointed at the anonymous
database instead of the user's own.
The date rule. A trip is only cached when it has not ended, and one that
ended over a week ago is actively evicted. The per-trip switch was an
opt-out list, so ticking a finished trip changed nothing at all. For
anyone whose trips are all in the past, "Download for offline use" stored
none of them, reported success, and the cache stayed at zero. Switching a
trip on now pins it, which overrides both the skip and the eviction, and
the switch shows the honest position rather than promising storage that
never happens.
What the screen says. A sync that never started (already running, offline,
signed out) was indistinguishable from one that stored everything: the
progress bar painted full and a green "Ready for offline use" appeared over
a run of nothing. Both syncs now report why they stopped and the screen
says so. A rejected Dexie read no longer renders as "No trips cached yet",
and the clear button stays reachable when the database holds data that no
bookkeeping row points at. A run that rejects outright says so too: the trip
list it starts from is a plain fetch, so a network blip took the whole thing
down and left the last progress frame on screen looking like a finished
download. The 401-with-HTML branch in the API client now
confirms a reachable proxy before tearing the worker down, since several of
TREK's own routes answer 401 with a text/html body.
Both settings shells move onto one shared hook rather than growing the same
logic twice, and the tile prefetch caps its depth when the browser refused
persistent storage. Opaque tiles are billed at about 7 MB each, and an
origin that is not exempt from eviction loses its whole bucket, precached
shell included, which Workbox then never refills because sw.js has not
changed.
Closes#2228
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.
The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.
Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.
Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.
Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.
Closes#2227
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning
A plugin whose author has not updated its `trek` range for the running TREK can
now be installed, updated and activated when the operator sets
TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker,
post-extraction re-check, sideload, dev-link, activation) warns instead of
refusing; a missing range is tolerated the same way; "install latest" takes the
newest published version. The plugin-API version gate is not lifted and the
Discover compatibility verdict stays truthful.
The admin is warned at every step: each bypass is logged, install/upload/link/
update responses carry a `trekRangeBypassed` marker, and both admin panels show
a "Version checks off" pill, an "Install anyway" confirm dialog before a
registry install, the same notice after a path that could not ask first, and a
persistent chip on the row while the plugin runs outside its range.
Nine new i18n keys in every locale; wiki pages updated (Environment-Variables,
Admin-Plugins, Plugin-Development, Security-Hardening).
* refactor(plugins): one useRangeBypass hook behind both admin shells
SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass
logic had been added to the desktop and phone plugin panels as identical
copies. The marker/warning types, the row chip, the "Install anyway" offer,
the dialog copy and the confirm-or-notice state now live in
useRangeBypass (next to useInstanceSettings, the existing shared-logic
precedent); each panel keeps only its own dialog/sheet markup.
* docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule
The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3%
duplication, A ratings, hotspots reviewed) was only discoverable by waiting
20-25 minutes for the workflow. The root CLAUDE.md now states the conditions,
the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and
how to check a PR with the sonarqube MCP first; client and server CLAUDE.md
point at it. shared/CLAUDE.md says every locale needs a real translation —
parity cannot tell an English placeholder from a translation.
* docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy
Verified every concrete claim in the five CLAUDE.md files against the code
and fixed the stale ones: the websocket server is the Nest realtime gateway,
MCP tools live in <domain>.mcp.ts files, the client "native shell" section
described code that was never written, offline maps also prefetch vector
basemaps, plus several renamed files and wrong table names.
Then trimmed everything that drifts on its own: dates, migration history,
counts, and inventories of stores, slices, domains, sync files and CLI
commands. What stays is the commands, the invariants with the file that
enforces each one, what CI actually runs, the reference implementations to
copy, and the rules for new code. Each file now opens with that rule.
The content browser gets a filter over the journey's pictures: all of them,
the ones no entry holds, or one entry at a time, with the day and the place
under each name. An entry card in the Entries tab reaches its own pictures
the same way. Pictures can now come in without leaving Studio, into the
entry the filter names or into the gallery, from the panel or dropped
straight onto the page, and a photo element can fill its page or the whole
spread in one press.
Stops can be taken out of the arithmetic. A journal entry carries a
stats_excluded flag; an entry that has it is off the route, out of the
distance, out of the countries and not a step, while staying in the
journal. Studio lists every stop in the travel panel with a switch, the
entry editor carries the same switch on desktop and phone, and the entry
card says when a day is off the route.
* feat(shared): scope plugin action descriptors
* feat(plugin-sdk): instance-scoped settings actions
* feat(plugins): run instance-scoped actions from the admin settings dialog
* feat(admin): instance-scoped plugin actions in the settings dialog
* fix(plugins): surface instance actions without instance fields and harden the action gate
* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope
* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window
* docs(wiki): plugin pages for instance actions and the admin panel changes
The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total.
* feat(plugin-sdk): settings-field default + unknown-attribute warning
* feat(shared): default on the plugin settings-field contract
* feat(server): persist and serve plugin settings-field defaults
* feat(client): pre-fill plugin settings defaults in the admin and user forms
* feat(server): enforce required plugin settings on both save paths
* feat(client): block plugin-settings save on an empty required field
* feat(server): resolve plugin settings defaults at runtime
A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.
Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.
* feat(client): share the plugin settings form rules across all three forms
The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.
* feat(plugin-sdk): seed dev fixtures from manifest defaults
`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.
* docs(plugins): document settings defaults as runtime fallbacks
A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.
* add missing exports
A bag's weight was summed in the browser over the item list the viewer is
allowed to see. Personal items belonging to other members are filtered out of
that list by design (#858), so their weight vanished with them: two people
looking at the same shared bag saw two different totals, and neither saw the
real one.
That number is then measured against `weight_limit_grams`, an absolute airline
limit. A per-viewer subtotal there is not merely incomplete but wrong — a
shared bag could sit over its limit and warn nobody.
The sum now happens in SQL over every row and ships as `total_weight_grams` on
the bag, with `unassigned_weight_grams` alongside so the "no bag" pile and the
grand total follow the same rule; a screen mixing true totals with per-viewer
ones would be worse than either. Only integers cross the wire: a member learns
that a bag is heavier than the items they can see, never a name, category,
quantity or owner. That disclosure is deliberate and bounded, and it is what
the issue asks for.
The item lists stay viewer-scoped, so #1767 ("someone shared it with you, but
they are the one bringing it") is unchanged — it just no longer decides what a
bag weighs.
Totals also had to stay live, because the reported repro is two people looking
at the same screen. A private item's event reaches only its owner, which is the
very rule that made the totals wrong, so item writes now also ping the room
with a content-free `packing:bag-totals` and everyone re-reads the numbers.
All three surfaces — the bag sidebar, the bag modal and the mobile sheet — read
the server figure and fall back to the local sum only for a bag cached offline
before this change.
Closes#2191
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
#1260 drew the GPX tracks of every trip behind a journey onto its map, with
nothing to switch off — the reasoning being that a route imported into a
linked trip is part of the journey's story. For a trip carrying a season of
recorded drives it is instead a map nobody asked for, drawn from places that
never became an entry.
So it becomes a journey-level setting, owner-only like the other Journey
Settings, and it starts off: a journal should show what its author put in it,
and #1260's set is everything the linked trips happen to contain. The switch
gates the request, not just the rendering — the endpoint ships full unthinned
geometry, which is megabytes fetched on every journey open for a map nobody
wanted.
`journeyTracks()` itself is deliberately untouched, so TREK Studio keeps the
set its saved books were laid out with.
Also fixes what the reporter's screenshot actually shows: JourneyMap fed track
coordinates into `fitBounds`, so one drive across a country zoomed the journey
map out until the entries were specks. Tracks now only frame the map when
there is nothing else to frame, which is what JourneyMapGL already did — the
same journey used to open at two different zooms depending on the renderer.
Closes#2194
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
The three stacked sections become one grid of cards per group, with the
type shown on the tile itself. Bag tracking, the collab features and the
photo providers hang off their parent addon as compact shelf rows
instead of full-width siblings, and each group header counts how many of
its addons are on.
Along the way:
- naver_list_import, airtrail and llm_parsing get catalog entries, so
their names and descriptions translate like everyone else
- dark detection moves into a shared useIsDark hook that watches the
.dark class instead of re-deriving settings + matchMedia locally
- the ToggleSwitch knob uses --accent-text so a light accent in dark
mode no longer renders white-on-white
* chore: bump version to 4.1.0 [skip ci]
* fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone
Three places answered "is this a place the trip already has?", not two:
PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy
list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and
kept its own hand-rolled copy of the pre-#2130 order.
Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of
it — savePlace calls it directly — so the coordinate fallback for a NAMED
candidate was live, not latent: two distinct places sharing an address (a
restaurant and the bar upstairs) would report each other as duplicates the
moment their coordinates round to the same ~11 m box, regardless of name.
It also never read google_place_id/google_ftid/osm_id at all, even though
every collection_places row stores them, so a place renamed since it was
saved could be saved again instead of being recognised by its provider id.
Now walks placeMatchStrategies() from @trek/shared, same as
findMatchingPlaceId: provider id, then name, then coordinates only when there
is no name. savePlace passes the provider id fields through since they are
already on the request body; the two bulk call sites (importablePlaces,
saveFromTripPlaces) are unchanged, since their source rows don't carry
provider ids to pass.
Closes#2137
* fix(collections): ask the same question at all three call sites
savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy
already carries google_place_id/google_ftid/osm_id into the row it writes but
asked without them, and the import picker never selected them at all, so a
renamed place was recognised on one path and not on the other two. The picker
answering differently from the import is the drift the method exists to prevent:
a row shown as new would come back refused.
COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker.
* fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down
Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom
specified", and so could the map settings tab, which left no screen from which to
pick a different basemap.
Leaflet answers getMaxZoom() from the map options first and otherwise from a
layer that brought one, and only a GridLayer ever contributes via its beforeAdd
hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes
nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd
refuses an infinite one by throwing, before any clustering, so an empty trip hit
it too.
That path opened when the default basemap became a MapLibre style. It reaches
further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless
CARTO template to the app default, which is now a vector style, so every
instance still carrying the old CARTO basemap fell into it on upgrade.
The ceiling belongs on the map rather than on the base layer, because it has to
hold whichever of the three branches renders. 19 matches what the raster and
satellite layers already carried, so nothing changes for the maps that worked.
SharedTripPage gets the same treatment: it has no cluster to throw, but it draws
the same vector basemap and its fitBounds asks for a ceiling.
mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library
rather than a mock, since a mocked react-leaflet would have kept passing through
all of this.
* fix(map): put the CARTO key back on the template the settings preview draws
The map settings fields hold what the user is editing rather than what
useTileUrl already resolved, so the preview received a bare template with no key
on it. resolveTileUrl then read it as a keyless CARTO url and did what it is
meant to do with one: fall back to the app default. The preview drew
OpenFreeMap while the fields said CARTO, whether or not a key was saved.
Same reason the tab crashed for anyone on a CARTO basemap even after entering a
key, which is the loop that made this hard to escape: the key is entered on the
page the fallback had taken down.
FE-COMP-MAP-033b pins the template the preview is handed, with and without a key.
* fix(admin): put the CARTO key back on the default-settings map preview too
Same shape as the user-facing map tab, and reported alongside it: the field holds
what the admin is editing, so the preview resolved a keyless CARTO template and
drew the app default instead of the basemap being set.
* fix(mobile): put the CARTO key back on the phone map settings preview too
The third preview with the same shape, and the one that matters most while the
crash is live: the phone layout is where someone would go to change the basemap
when the desktop tab will not open.
* fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar
Closes#2136
A stay was always exported as an all-day block across its whole range, and the
check-in and check-out markers came on top of it. When the stay records both
ends of its clock the markers already say everything the block does, and the
block is the half nobody can act on: it takes a week of calendar to repeat what
two one-hour events state precisely. Same shape as the parking and rental
windows in #2068.
Only where the markers actually stand in for the booking, though. They are
emitted once per stay and titled from its lowest-id reservation, so a second
room on the same stay would otherwise be left with nothing at all. A stay that
knows only one end keeps its block too, since nothing else carries the other
end's date.
CAL-025b covers the drop, -025c the one-sided stay, -025d the second room.
* fix(mobile): the phone admin map preview needs the CARTO key too
The fourth preview with the same shape, missed when the other three were fixed.
Sonar pointed at it: the desktop and phone admin pages are near-identical, so the
duplication report named the file the change had not reached.
* chore: bump version to 4.1.1 [skip ci]
* feat(plugins): admin editor for instance-wide plugin settings
The scope:'instance' settings a plugin declares had API endpoints but no UI.
- shared: plugins.schema.ts — the settings-field descriptor and the admin
instance-config GET/PUT wire contracts, imported by both sides
- server: GET /api/admin/plugins/:id/config now returns the declared fields
alongside the (masked) values; PUT re-spawns an ACTIVE plugin (its child
reads config once, at init) and reports it via 'restarted'; the admin list
carries instanceSettingsCount so the UI gates its menu item without a fetch
- client: 'Instance settings' row action opening a form modal (desktop) /
MSheet (phone) in both admin shells — checkbox/select/secret rendering as
in the user settings form; an untouched secret mask is never sent back
- i18n: three new admin.plugins.* keys in every locale
* refactor(plugins): move the admin body contracts to @trek/shared
The remaining plugin DTO schemas (install, link, activate, uninstall,
retrust, update, egress-hosts, user-settings update, route) now live in
shared/src/plugins/plugins.schema.ts with their inferred types, alongside
the instance-config contracts. The journey-contract doctrine notes (#1842)
moved with them — each body stays deliberately loose so the handlers keep
the rejections and fallbacks they already own, and new spec tests pin that
looseness so a well-meant tightening breaks a test that explains itself.
plugins.dto.ts is now thin createZodDto wrappers over the shared schemas
(same class names, no controller changes), and the client's pluginInstall
types its options and body against PluginInstallRequest.
* fix(admin): offer Allowed hosts only to plugins that declared operatorEgress
The row's egress chip was already gated on the declaration, but the kebab
menu (desktop) and row action sheet (phone) offered Allowed hosts on every
installed plugin — inviting the admin to widen egress for plugins that
never asked for it. Both entries now render only when the plugin declares
operatorEgress; the dialog's unsupported-notice fallback stays as the
truth-teller for stale list data.
* test(admin): close the Sonar gaps on the instance-settings form
The PR gate flagged new-code coverage (76.4%) and duplication (10.9%).
- The two identical 39-line handler blocks were logic, not markup — they
move into one shared useInstanceSettings hook; each shell keeps its own
markup (the deliberate desktop/phone divergence stays)
- New tests per shell cover the untested branches: every field type
renders and round-trips into the save payload (select, checkbox/MToggle,
number, hint, no-label key fallback), a failed fetch is a toast, a
rejected save shows the server reason and keeps the edits, and the
dialog/sheet closes without saving
- INS-009 covers the controller's no-body '|| {}' fallback
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: michael-bohr <263459669+michael-bohr@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
The manifest declaration only becomes an admin-visible control if the
admin can actually read it. Nothing rendered capabilities.mcpTools, so
approving mcp:tools meant approving text you would first meet inside a
chat.
The pre-install dialog now lists the tool names and descriptions, beside
the existing access and egress sections, and the grant gets an
at-a-glance chip like every other capability.
Desktop and mobile both, since the panels are parallel copies.
Modelled on the hook:* family rather than on geolocation:read. The host
dispatches INTO the child for every tool call, so it needs what
HOOK_PERMISSION buys: providersOf()'s active-and-holds-the-grant check,
the second gate in invokeHook, and the acting-user binding. That last one
is the point. The user is bound host-side from the supervisor's
invocation map, so a tool called by user A reaches A's data whatever the
model puts in args.
The permission keeps no hook: prefix because the surface it opens is MCP,
not a TREK render slot, and nothing keys off that prefix. It is the first
HOOK_PERMISSION value shaped that way; `satisfies` still holds.
One commit, because it cannot be split. requireTotalCoverage makes a
HOOK_PERMISSION key with no @PluginHook consumer a boot failure, not a
test failure, and PluginRpcRegistryService.onModuleInit runs in every
buildApp() — so a half-landed version breaks every integration test in the
repo. The generator is chained to it too: it refuses to run without both
the en admin label and the CLI hint.
15s is the longest budget of any read hook. The others back a render
someone is waiting on and fall back when they time out; this one backs a
chat turn, and the plugin is likely calling a third-party API of its own.
Still under the supervisor's unused 30s default, which is a hung client
rather than a budget.
One callTool fn for every tool, since tool names are runtime data and
hookContracts() is keyed by (hook, fn).
PluginDefinition exists in two copies with no generator between them, so
both get McpToolProvider. grantGaps and the dev mock host need no change:
both read HOOK_PERMISSION, which is generated.
PLUGHOOK-010, plus the CONTRACTS row.
One coarse scope rather than per-plugin granularity. Which plugins are
installed is runtime data, so a per-plugin scope cannot be statically
enumerated: it would break the ${group}:${mode} derivation and the
AssertExact guards in nest-mcp-policy.ts, and its consent copy could not
be written ahead of time for a third-party plugin name. Tokens issued
before a plugin existed could never carry its scope either. Per-plugin
granularity already exists one layer down as the admin's mcp:tools grant.
'use' is a fourth mode, which widens ScopeMode. Nothing else moves:
McpAccessGroup and McpAccessMode derive from Scope, so both AssertExact
guards self-heal, and the policy's fallback branch already covers a third
mode the way it covers files:content.
The presets needed an explicit exclusion. Five of the six are written as
"everything except deletes", so plugins:use would have joined Claude.ai,
Cursor, Windsurf and Zed silently and handed out third-party tool
execution without anyone choosing it. That filter was duplicated across
the desktop and mobile files with nothing pinning it, so both now use one
shared constant, and FE-OAUTH-SCOPES-012 holds the line for the next
scope like this one.
The consent copy says what the scope does not do: the RPC host authorises
from grants frozen at construction, so this scope plus weather:read still
reaches whatever the plugin itself was granted.
Two i18n gates, not one. The dot-only group key is tracked by
i18n-parity.mjs, whose regex has no colon in its character class; the
colon-bearing label/description pair is invisible to it but is compared by
client/tests/unit/i18n/parity.test.ts, which diffs merged bundles. All 23
locales get all three keys, including the three in neither gate.
The consent glyph gets a ':use' branch. Falling through to the eye would
tell the user a tool-running scope only looks at things.
Two consumers decide whether a candidate place is one the trip already has,
and they spelled the rule out separately: the bulk importers ask an in-memory
set (isPlaceDuplicate), while findDuplicatePlace asks the database because its
caller needs the matched row's id. The two had drifted — the SQL copy falls
back to a coordinate match for a NAMED candidate, which the in-memory copy
deliberately never does, since that would merge the restaurant and the bar at
the same address.
The order now lives once in @trek/shared as placeMatchStrategies(), and each
consumer is a mechanical interpreter of it: provider id (the only part that
survives a rename, #1550), then name, then coordinates and only when there is
no name.
Adds PlacesService.findMatchingPlaceId as the public door for an importer that
has to link to the existing place rather than merely skip the candidate.
findDuplicatePlace stays private behind it — it also returns google_ftid for
the bulk importer's backfill, which is that caller's business and not part of
"which place is this?".
No behaviour change for either existing caller. The coordinate branch is
currently unreachable from the one call site, which sits inside an
isPlaceDuplicate() guard that has already matched on name and returned; the
drift only bites once something calls it standalone.
COORD_DEDUP_TOLERANCE and externalIdsOf move to shared and are re-exported from
places.helpers, so collections.service and places.service keep their imports.
CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile
on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves
a self-hosted instance needing an email exchange with CARTO before its maps
look right, and every default in the client still pointed at CARTO. Default
to OpenFreeMap Positron instead: no key, no account, no request limit,
commercial use allowed, attribution required.
OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre
style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL
canvas into Leaflet's own tile pane, which leaves everything above it alone -
markers, GeoJSON, clusters, plugin layers, the custom panes, and the click
handling, since Leaflet's CSS already puts pointer-events:none on that layer.
maplibre-gl still arrives only through a dynamic import, so no chunk that
draws a map grows by a megabyte.
Switched: the trip planner, the MapView fallback every preview goes through,
the journey map, collections, the atlas, the shared trip page and the offline
tile prefetch. OpenFreeMap Positron and Bright join the tile presets in
settings, the admin defaults and both mobile screens.
A CARTO template that is already saved keeps working when a key backs it -
that stays a supported choice. Without a key it is not drawn at all:
resolveTileUrl falls back to the default rather than to a watermark, so an
instance that upgrades with one in its settings sees the new basemap
immediately and needs no migration. The stored value is left as it is, so the
Map settings tab still shows what was chosen and the warning underneath it
explains why - one sentence appended, in all 23 locales.
OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles
is wrong and a licence problem. attributionForTile() derives it from the URL
rather than from a flag at each call site, and the shared trip map, which
carried no attribution at all, now gets one.
Offline maps keep working and get cheaper. A vector style is four things
instead of one, so sync/glPrefetcher.ts walks the style document, its sprite,
the Latin glyph ranges of each font stack it uses, the TileJSON - the planet
path is versioned, so guessing it answers 200 with an empty body - and then
the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is
2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177
requests and 32.1 MB and stays sharp above that, because MapLibre scales the
z14 tile rather than asking for another. The Workbox rule for
tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache,
so browsing tops a downloaded region up instead of filling a second copy
beside it - a versioned tile path can never be stale. Clearing offline maps
clears it too.
The atlas loses its second raster layer. It existed to warm the HTTP cache of
neighbouring zoom levels, which a vector basemap does not need; a second one
would only cost a second WebGL context. OpenFreeMap has no label-free style,
so its labels are switched off on the live map instead - they are all symbol
layers, while borders and coastlines are line and fill - re-armed on
style.load so a theme switch keeps them off.
Theme changes call setStyle in place rather than rebuilding the layer: a swap
per toggle would leak a WebGL context, and browsers cap those in the low
teens.
Auto layout wrote journey data straight into the document without holding
it to the caps the contract sets, and the save path sends the document
unvalidated - so the route refused the whole body with a 400 and the
editor reported "couldn't save" for the rest of the session, because
autosave kept offering the same rejected book.
The everyday way in was the itinerary list: thirteen stops a page over
two pages is eighty elements on a spread capped at sixty, so a run of
twenty bare stops was unsavable and a longer one put two spreads over.
Sixty was below what this app's own layouts draw, which makes it a bug
with a constant in front of it rather than a limit, so the cap goes to
ninety and the page keeps the design somebody chose. It stays a
serialisation limit: the largest document the contract now admits is
under four megabytes against the book route's eight.
A long journal entry, a long place name, a journey past eighty countries
and a long title each did the same thing on their own, and a bound story
did it on open without anyone touching Auto layout. The caps become named
exports on the contract and every producer builds against them, so the
two cannot drift apart again - which is also why the stations chunk is
derived from the cap and the salvage fixture counts off it.
Fixes#2085
A journal importer can create the journey, create its entries and write their
stories, and then it stops: there was no way to give an entry a picture. The
seven journal.* methods cover text and nothing else, so an importer reading an
export archive could only upload its photos as trip files, which live on a trip
and never reach the journey's gallery.
journal.addEntryPhoto takes the bytes. Deliberately not a link to an existing
gallery photo and not a provider asset: an importer holding an archive has
neither, it has a file it just unpacked.
The access decision stays where it already was. addPhoto answers null unless the
acting user may edit the journey the entry belongs to, and null becomes
RESOURCE_FORBIDDEN here, so a plugin reaches exactly the entries its acting user
does. Everything the REST upload refuses, this refuses too: images only and no
SVG, the operator's allowed-file-types setting, 10MB decoded, demo mode. The
stored filename is the host's, never the plugin's, because the gallery dedupes
on file_path. The object is written before any row points at it and removed
again when the link is refused.
JournalRpc moves into its own module for this. It now needs StorageService and
the EXIF backfill out of MemoriesModule, and JourneyDomainModule exists
precisely so places, assignments and the plugin host can reach a few journey
functions without dragging both photo providers into their graphs.
Discussion #1365
Homepage's customapi widget and everything shaped like it want a handful
of numbers from one request; /api/v1/trips hands out the rows, but a
widget maps fields, it cannot aggregate a list.
The figures come from AtlasService.getTravelStats, the same call behind
the dashboard's passport card, rather than from a second count. Visited
countries carries years of rules — countries reached only by a flight,
layovers that do not count, countries hidden by hand — and a second
implementation would disagree with the card next to it within a release.
That is also why the controller sits in atlas/ and not in public-api/:
PublicApiModule stays a leaf, so the route comes to the data instead.
TravelStatsController next door already does this for
/api/auth/travel-stats. ApiTokenGuard is provided locally rather than
imported with PublicApiModule — @UseGuards instantiates a guard in the
declaring controller's module, so an export would still leave its
TokenService unresolvable here.
last_trip means the most recently started trip, not the most recently
created one, and is null while every trip is still ahead.
Closes#1367
Reading the surface back against real data turned up three things it dropped
without saying so.
Places with no day were invisible, because the payload only reached places
through day_assignments. On a live instance that is half of them, and they
are the ones carrying a coordinate and no claim about when — exactly what an
integration can act on. They come back under `unplanned_places`, minus the
hotel, which is already reported as an accommodation.
Bookings with no day were dropped by the day filter. reservations.day_id is
nullable and deleting a day detaches its bookings rather than removing them,
so this is not a hypothetical.
The bucket list had no endpoint at all. It hangs off the user rather than a
trip, so it gets its own, and it answers whether or not the Atlas addon is
switched on: the addon decides what TREK shows, not what a key can read.
Also fixes ?include=notes returning the bare trip: places, notes and
reservations are reported on days, so asking for one now implies days
instead of quietly answering with nothing.
The module stays a leaf. Reaching for AtlasService pulled AuthModule and the
storage registry in behind it and broke the surface's own boot, so the one
SELECT lives here like every other query in the file.
Minting a key for other software meant finding it under MCP, behind a
heading about AI assistants and gated on an addon it does not need. It now
sits on its own in Settings, above the MCP block, and works on an instance
with MCP switched off.
Deliberately separate state rather than another branch of the tab's shared
hook: the section has to stand on its own, and that hook is already carrying
forty fields through three levels of props.
Translated into all 23 languages.
An MCP token drives every assistant tool; an API key reads trips over HTTP.
Handing the same credential to a third-party integration gives it the whole
MCP surface, so the two are now separate kinds on `mcp_tokens` and the kind
sits in the WHERE clause — a token of the wrong kind is indistinguishable
from one that does not exist.
Also adds `travellers` to the trip payload: display name and an owner flag,
no ids and no email addresses. An integration key reads its owner's
itinerary; it is not a way to enumerate the people around them.
Documents the surface in the wiki, since integrators need something to read
that is not the source.
Two gaps from the first pass, both about a client that polls.
Rate limiting is keyed by user rather than by IP. A self-hosted integration and
its owner's browser routinely leave the same address, and an IP bucket would let
one starve the other; 120 requests a minute is generous for a sync surface and
still bounded, so a runaway poll degrades its own integration instead of the
instance.
updated_at now ships with every trip, and its docblock says plainly what it is
not: a content timestamp. It moves when the trip's own fields change — title,
dates, currency, cover — and stays put when a place, a day or a note is edited,
because the child tables carry no updated_at of their own. That also rules out
the ?since= filter this was meant to enable: it would silently hide every trip
whose itinerary changed, which is worse than no filter at all. Making it honest
needs a schema change, not an API field, so the field is documented for what it
is and the filter is left out.
TREK's REST surface is internal: it answers a session cookie, carries no
version, and changes with the client. That is fine for our own frontend and
useless for anyone building against us — the Dawarich integration people have
been asking for since April needs something it can rely on not moving.
So this adds a small one rather than opening the existing 549 endpoints:
GET /api/v1/trips the trips the caller can reach
GET /api/v1/trips/:id one trip, ?include= picks the sections
Auth reuses the trek_ tokens users already manage under Settings, via Bearer
or X-API-Key. No new credential type, no new table — the guard calls the same
hash lookup the MCP transport does. It deliberately refuses a session JWT and,
for now, OAuth bearers: a credential whose scopes this surface does not yet
interpret is worse than one it rejects outright.
Access is decided per trip against canAccessTrip, the same owner-or-member
predicate as everywhere else, and a trip the caller may not read answers 404
identically to one that does not exist — a 403 would confirm the id is real and
turn the endpoint into a way to count someone else's trips.
The payload is not the rows. Ids, foreign keys and order_index stay inside;
accommodation day ids resolve to ISO dates, places arrive in planned order, and
every dated thing carries its date so a consumer can join on days rather than
on ids it has no way to look up.
shared compiles with noUncheckedIndexedAccess, so a capture group is
string | undefined there even when the pattern guarantees it. Pull the groups
through optional access with explicit fallbacks instead of indexing into the
match, which also drops the two throwaway locals.
No behaviour change: same inputs, same outputs, the spec is untouched.
splitIso reached the time by taking characters 11..16, so "2026-06-11T01:11 PM"
became 01:11. The hour survived and the half of the day did not, which is why
AM and 24-hour times in the same document imported correctly. The arrival then
sorted before the departure and the router rolled it onto the next day, so a
three-hour hop was rendered as an overnight.
The client has done this properly since #1725; this is the server half, which
was never pulled across. normalizeLocalDateTime rewrites a value only when it
actually finds a meridiem, so a date-only value, a value carrying an offset and
anything the parser does not know come back byte for byte.
The mapper has only two exits for a date, toIsoString and splitIso, so this
covers all eight types and every emitted field at once. It also stops
sameConnection comparing a NaN timestamp, which silently merged unrelated legs
into one booking.