* Offer Waze and Apple Maps beside Google Maps
A place could only be opened in Google Maps, and the request in #919 was for the
apps people actually drive with. Google keeps the precise link it always had:
ftid, then place id, then the details URL, and coordinates only as a last resort,
so it still lands on the entry inside a mall rather than on the roof. Waze and
Apple Maps take coordinates and nothing else, which is why they are absent for a
place with no position, and why they open with navigation armed instead of on a
place view.
No setting. Every app that can resolve the place is offered, except Apple Maps,
which is dead weight on Android and Windows and therefore only appears on Apple
platforms. iPadOS reports itself as a Mac; both sides of that ambiguity have the
app, so the check does not need to tell them apart.
The picker is a new component rather than the context menu, which has no notion
of a submenu. It is portalled because both call sites sit in panels that clip
their overflow, and it is measured before it is painted: near the bottom of the
window it flips above the trigger instead of running off the screen, and it is
pulled back inside when it would overflow to the right. With a single app left
to offer there is no menu at all, the button opens it directly, exactly as it
behaved before.
The three surfaces take it the way each has room for. The inspector button
collects them behind one entry, the day-plan context menu lists them flat
because a menu can grow, and the phone keeps its single circle and opens the
picker as a sheet.
* Move OpenStreetMap into the navigation menu, and name the destination
OSM was still a button of its own beside the one that now collects the map apps,
which is the arrangement the menu exists to end. It belongs with the others.
The bigger correction is what Waze and Apple Maps are handed. Both document a
query parameter next to the position, and the pair is what makes a destination
legible: coordinates say which place is meant, the name is what the driver reads
instead of a number. Neither gets a name without a position, because there are a
lot of places called "Bahnhof". Google is unchanged; it can already do better
than a name through its place id.
* Stop drawing the section count as a pill inside a pill
SectionHeader wraps its count in a CountPill, and all three callers passed one
in already, so every section header carried two stacked pills: a lighter
rounded background peeking out from behind the real one. Pass the number and
let the header do the wrapping.
* Give the traveler filter a label and a way back
The avatars floated between the toolbar and the first section with nothing to
say what they were, and once a filter was on the only way out was remembering
which face had been tapped. It is a labelled bar now, closed off by the same
divider the rows below use, with an explicit reset that appears while a filter
is active. Both texts already exist in all 23 languages.
* Line the traveler filter up with the section titles
It carried the sheet's usual 18px of horizontal padding while SectionHeader
right below it starts at 2, so the two labels sat on different left edges.
* Filter the place list by stars instead of sorting by them
Sorting put the best-rated place first and left every other one on the list,
which does not help when the question is "what does the group actually want to
do". It is a floor now, the same one the collections bar uses: All, or 5+ down
to 1+, with unrated places dropping out once a floor is set. The button carries
the active floor so the list is never quietly shortened.
The row it sits in used to render only when the trip had categories, which hid
the star control on a fresh trip. That is exactly where someone collects places
to rate, so the row is always there now and only the category dropdown depends
on there being categories.
places.sortByRating is replaced by places.filterByRating in all 23 languages,
since the old label described a behaviour that no longer exists.
* Keep the collections map when a filter matches nothing
Clicking a label with no places behind it made the map disappear and the page
reflow to full width. The layout hung off the FILTERED set: no visible places
meant nothing mappable meant no split. A filter should empty the map, not tear
it out, so the layout now asks whether the list has anything mappable at all,
while the markers keep following the filter.
* Give the label filters a tray of their own
Two changes to the filter row. Select and manage-labels drop their captions and
keep icon, title and aria-label, which puts them on the same footing as the
add-place button that was already icon-only.
The labels themselves were pill-shaped buttons with their own borders sitting
loose among square-cornered dropdowns, so a list with a handful of them read as
a pile rather than as one control. They share the filter buttons' radius and
type now, and sit in a recessed strip led by a tag glyph: the chips carry no
border of their own, because a frame inside a frame was half the noise. Manage
stays quiet at the end of the strip until hovered.
* Put the collections controls on one line
The filter row sat at about 31px while the buttons floating over the map are
38, and the icon-only add button was the odd one out even within its own row.
Scrolled to the top, where the row and the map's top bar are side by side, that
reads as three different heights.
One --col-ctl-h for both, fixed rather than derived from padding so a button
with an icon and one with a word cannot drift apart, and the row starts at the
same offset the map's own bar does. Phones raise the variable instead of
patching individual min-heights, which keeps the square buttons square.
* Offer Apple Maps on desktops that are not Apple
Apple Maps has had a web version since 2024, so the link resolves to a usable
map on Windows and Linux rather than to nothing. Hiding it there was a guess
about a limitation that no longer exists.
Android keeps the exemption: the web version works there too, but nobody
navigating from an Android phone reaches for Apple Maps, and the row of choices
is short on purpose.
* Move the label filter into the map's top bar
The filter row was running out of room while the map's own bar had space to
spare, so the labels ride up there next to the collapse button, in the same
glass as the controls beside them and scrolling sideways rather than wrapping.
They fall back into the filter row whenever no map is on screen — a narrow
window in list view, or a list with nothing mappable in it — so they can never
end up somewhere the user cannot reach. Both places render the same component
now instead of two copies drifting apart.
* Stop the active select button drawing a dark icon on a dark fill
Two rules with the same specificity claimed the same class. On a dropdown
`.open` means the panel is down, so the label stays dark; on the select button
it meant the mode is on, which fills it with the accent and needs the light
text. The dropdown rule sits later in the file, so it won, and the icon
disappeared into the fill the moment the button was clicked.
The state is `.on` now, which is what the other toggles on this page
(col-map-btn, col-iconbtn) already call it.
* Open the place in Google Maps, not the coordinates
Reported in #1278 and still reproducible: without a Google identifier the link
carried nothing but latitude and longitude, so Maps dropped an unlabelled pin
instead of opening the entry. The name and the address were both on hand and
both thrown away.
Google documents query=PLACE_NAME,ADDRESS for exactly this case, so that is
what the fallback sends now. A name with no address still falls through to
coordinates, because "Central Station" on its own would confidently open the
wrong city, and a pin in the right spot beats a link to the wrong place.
The precise paths are untouched: an ftid or a place id still wins, and they are
what a place searched through Google carries. This only changes what happens
for the ones that carry neither, which is every place added from OpenStreetMap,
by hand, or before those fields existed.
* Put the atlas sparkline behind the text it was covering
The graphic is absolutely positioned and comes after the label, value and delta
in the DOM, so with no stacking order of its own the browser painted it on top
of them. On the dashboard's atlas row a status card is about 190px wide at
1440px, and the 80px sparkline covered more than half the delta line (#1787).
Give it z-index 0 and lift the three text rows to 1. It is decoration, so it
stops taking pointer events while we are here.
* Let a sentence-length sheet subtitle wrap
TileHeader truncates its subtitle to one line. That is right for a filename or a
date, and wrong for the reorder hint: between the 40px icon tile and the 34px
close button there is not enough width for a sentence, so it was cut off
mid-word (#1814), and every translation is longer than the English one. Opt in
with subWrap where the subtitle is prose; the file sheets stay single-line.
* Translate the phone entry view
MobileEntryView carried its own English label tables while the desktop view
resolved the same values through i18n, so a German reader got "Amazing" and
"Sunny" on a phone and the translated chips on a tablet (#1846). Which view
appears is decided by two different breakpoints, which is why it looked
arbitrary. The labels are keys now, the edit action, the photo counter and the
pros/cons headings go through t(), and the date formats against the chosen
locale instead of the browser default. No new keys: all of them already exist
in all 23 files.
The colour classes stay local. The desktop table carries raw hex values and no
dark-mode variants, so sharing one table would change how this looks.
* Name Google account linking in the Cloudflare bot section
The section claimed the block was specific to ChatGPT, so someone hitting it
with Gemini had no reason to read on (#1667). Google exchanges the code from its
own servers, which Bot Fight Mode blocks the same way, and the symptom differs
enough to be worth spelling out: the browser half succeeds and linking fails
afterwards, with a 200 on /api/oauth/authorize and no /oauth/token at all. Also
note that Bot Fight Mode ignores IP allowlists, which is the first thing people
try.
Both advisories are the same mistake: a route that hands out a bearer token
treated trip membership as authorisation. Membership lets someone read the trip
while they are signed in. It does not let them mint a URL that reads it without
an account, keeps working after they are removed, and can be rotated out from
under the owner's calendar subscriptions.
The calendar feed token now sits behind TripAccessGuard plus
@RequirePermission('share_manage'), the same shape the rest of the trip-scoped
controllers use, which also replaces a hand-written access check that GET never
called at all. GET is gated with the writes because it returns the credential
itself, not its status. The share link's GET does what its own POST and DELETE
already did, and what the invite link next door already does for exactly this
reason.
Two things had to come with it or the gate would have been decoration. The MCP
tool get_share_link read the same token on membership alone, so it would simply
have become the new way in. And feed_token rides along in every trip payload
because TRIP_SELECT reads t.*: any member could have read the value out of
GET /api/trips/:id, the summary tool or the plugin RPC. It is blanked once in
the select and stripped in the two places that select the row directly.
The three service writes take the acting user and scope their UPDATE to a trip
that user can reach. The guard decides who may manage the credential; this only
makes sure a caller arriving another way cannot write to an id it guessed.
Client side, "Subscribe to calendar" is now behind the same permission on
desktop and mobile. Without that a member would have kept an entry whose enable
button silently does nothing, because IcsSubscribeModal swallows every non-200.
The one-off ICS download stays where it is: that is a file they may already read.
Instances that want members to manage these links can lower share_manage to
trip_member in the admin panel, which the tests cover.
Landing on /login while already signed in, by typing the URL or going back, showed the login form again (#1810). PublicRoute takes a redirectAuthed flag on those two routes and sends the visitor to the start destination instead, capturing the auth state at mount so a fresh login is left alone during the takeoff animation. Only from a bare URL: ?redirect=, ?invite=, ?oidc_code= and ?oidc_error= all have to reach useLogin intact.
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.
Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:
- TagsController.create answers { error: 'Tag name is required' }, pinned
byte-for-byte in two tests, and the pipe formats as `field: message`. The
DTO leaves name optional and the handler keeps the check that owns the
message. Same shape for the booking-import confirm body, which additionally
gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
and check that BEFORE looking at the body. A schema that rejected first
would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
documented reset. Typing it as string[] would have removed a working admin
action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
it dislikes, so the client falls back to straight lines. A 400 there would
turn a graceful degradation into a visible error.
files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.
PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.
typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
Client CI was red on this branch while dev was green, and no client file had
changed. The 536 test files all passed; the job failed on three unhandled
rejections, all `window is not defined` from MDashWidgets, attributed to
whichever case happened to be running when they landed.
The widget fetches api.frankfurter.dev directly and sets state from both
handlers with no unmount guard. MSW runs with onUnhandledRequest: 'warn', so
that request was never intercepted: it left the runner, came back after the
environment was gone, and React tried to schedule an update against a torn
down tree. Load dependent by construction, which is why dev happened to pass.
Two halves, because either alone leaves the other broken. The widget now
aborts in flight work on unmount and on refetch, and treats an abort as "no
one is waiting" rather than as a failure to report. That matters outside the
runner too: this is a third party call on a mobile screen a user can leave
immediately.
The handler set gains the third party endpoints the app calls directly rather
than through /api, so no suite reaches the real host. An empty rate list is
the shape the widget already handles.
Also clears twenty more test files, each at its root cause. Two more were
passing for the wrong reason: oidc.service.test.ts built AuthService with an
AtlasService in the membership slot, undefined for userCleanup and mailer,
and the mailer stub in the tokens slot, the same shift admin.service.test.ts
had. And CAL-010 claims to serialise a flight byte for byte, but
createReservation ignores reservation_time, so the row is written with a null
time and CalendarService drops it: the recorded snapshot is truthful about
the data the case builds and silent about the flight it advertises.
Three things you could see on dev1.
Pictures were "roughly right" rather than right, because a coordinate search
only knows what was photographed within 300 m. Wikidata's P18 is one picture
somebody chose to represent that exact object - "Zurletzteninstanz.jpg" for the
restaurant, not a statue down the road - so it now leads, then the Commons
category, and geosearch is the last resort it should always have been. Small
venues carry a wikidata tag far more often than a Commons category, so this is
also what reaches the places the other sources miss.
Descriptions stayed on Wikipedia after the switch to Wikivoyage because the
week-long result cache predates it. It carries a format version now; a release
that changes the shape or the sources invalidates its own entries instead of
serving last week's for another six days.
Ratings and opening hours were being fetched from Google and then dropped on the
floor - the fact list only looked at OpenStreetMap tags. Both are shown now,
whichever provider supplied them.
Also: the column follows a place opened from the map, not only one picked from
search. A POI tap arrives as prefillCoords and never touched the selection, so
the column kept showing the previously added place.
Pictures are a tight three-column grid and only the picture in play is credited
in full, on one line. Crediting all of them at two lines each drowned the
column, and the licence attaches to the one that gets used - whose credit also
stays visible in the inspector after saving.
The request carries the place record the dialog already fetched, so the server
skips a details lookup it was making in parallel with the client's own. That
lookup was measured at 12.8 s for a large OSM relation.
A restaurant got nothing: no wiki article, and a coordinate search around it
returns whatever was photographed nearby rather than the place itself. Three
changes, all on free sources:
OpenStreetMap facts. A tagged restaurant carries its cuisine, its opening hours,
a link to its menu, outdoor seating, step-free access and diet tags. Those
arrive with the details lookup the dialog already makes, so showing them as
chips costs nothing and is the single biggest gain for exactly the places that
were empty before. Only positive facts are listed - "no outdoor seating" is
noise.
Commons categories. Where a place has a wikimedia_commons tag, its category is
pictures OF it; geosearch falls back in when the category is empty.
Wikivoyage before Wikipedia. Same API, same licence, but it describes a place
for someone about to visit rather than opening with area in square kilometres.
Two sentences instead of three, because this sits beside a form.
The column also follows the section-heading idiom Vacay and the dashboard share
(uppercase overline, wide tracking), its header matches the Saved places panel
beside it, and a lead picture with a row underneath replaces the two-column
grid that left a hole on every odd count. Credits wrap over two lines instead
of truncating - a cut-off name credits nobody.
Saved places stops after ten with a Show more button; search and filters still
run over everything, not just the visible page.
Form spacing is one rhythm (12px between groups, 8px inside, 4px label to
field) and Notes matches Description in height. The OSM notice fits on one line
and no longer claims there are no photos, which stopped being true when the
column started fetching them from Wikimedia. The clipboard hint is gone; paste
still works.
The column showed "details could not be loaded" for every place. The endpoint
was answering correctly, just after ten seconds, and the shared axios instance
gives up at eight.
The downloads sat in a loop: five Commons hits meant five round trips and five
decodes in sequence, even though the slot limiter already allows five at once.
Both provider listings now run together and the bytes are fetched concurrently,
which brings a warm request back to about a second.
Candidate keys move with it. They now number the provider's list rather than the
surviving strip, so a picture that fails to download leaves a gap instead of
renumbering the ones behind it - a key has to point at the same file on the next
request or the cached result goes stale against the photo cache.
The client asks for enrichment with a longer timeout, and the Wikimedia calls
carry one of their own: a cold TLS handshake from a fresh container was measured
at eight seconds on its own, and no pictures is a better answer than a request
that never comes back.
Accommodation cards read the place from page-local state, so a new thumbnail
only showed up there after a reload. place:updated now asks the planner to
reload them, but only when the image actually changed - renaming a place must
not trigger a refetch.
The inspector shows the stored credit under the avatar. Crediting a CC BY-SA
picture only in the moment it is chosen is not enough; the obligation travels
with the picture.
Also lets a GIF hero through to the PDF. Uploads accept gif, the PDF extension
check did not, so one silently fell back to the category icon.
Picking a search result fills a column on the left of the add-place dialog -
pictures and a description, no extra click. Someone who already knows the place
ignores it and saves exactly as before.
The column stays mounted with an empty state instead of appearing on the first
pick, otherwise the dialog jumps sideways mid-typing. Requests are cached in
memory and in sessionStorage and aborted when the selection changes, so clicking
back and forth between two results does not pay twice.
Every thumbnail carries its author, its licence and a link to the file page.
That is not decoration - most of these are CC BY-SA, and reusing one without
naming the author does not satisfy the licence. The description is shown but
never written into the place on its own; a button copies it across, and it is
disabled while the user already has a description of their own.
Desktop only. The mobile sheet is untouched.
Reads fail-open, unlike the three older places_* switches. Those read
=== 'true', which is why migration 185 had to backfill a row on every existing
install; there is nothing here that warrants repeating that.
The three hand-rolled toggles next to it move to ToggleSwitch while I am in
there - they were bare buttons with an inline transform and no aria-pressed or
label, so a screen reader could not tell their state.
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.
Pressing "log out" clears isAuthenticated while the page you were on is
still mounted, so ProtectedRoute re-renders for it and sends you to
/login?redirect=<that page>. Log out from the dashboard and the next login
goes to the dashboard - beating the startup destination every time after
the first, which is exactly how it looked: worked once, never again.
A session that ended on its own still gets the return ticket, because
coming back to where you were is the whole point of that parameter. A
deliberate sign-out is a fresh start and now gets none, so the startup
destination decides. The Navbar already hinted at this with its unused
noRedirect state; the flag lives in the store instead, since all seven
call sites of logout() need it, not just that one.
RootRedirect waited for whoever else might call loadSettings, which on a
cold start meant waiting for a request queued behind everything else a
launch fires off. Traced it in the browser: the settings came back as the
388th request, long after the redirect had given up and gone to the
dashboard. That is what "it still just opens the dashboard" was.
It now asks for the settings itself - the store de-dupes, so this costs
nothing when a load is already in flight - and login spends its 2.6s
takeoff animation fetching them instead of leaving that time idle, so by
the time it hands over they are already there.
Also folds the five copies of setShowTakeoff + navigate-after-2600ms into
one takeOff(), since all of them needed the same fix.
The mirror answered "dashboard" for a browser that had simply never been
told, and RootRedirect took that as the user's choice - so the setting was
ignored on every device except the one that set it, and on that one too
after a logout cleared the mirror. The setting itself was stored correctly
the whole time, which is what made it look like nothing happened.
readStartDestination now says "unknown" rather than guessing, and a device
in that state waits for the real settings instead. That costs one wait,
once per device; afterwards the mirror answers and the launch is as fast
as before. Bounded by a timeout, because loadSettings deliberately leaves
isLoaded false on a failed request so it can retry - without the floor, a
launch with no backend would sit on the spinner forever.
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.
Settings -> General -> Startup: start on the dashboard (unchanged default)
or straight in the active trip, on a tab of your choice. Three clicks and
three loads less for someone entering expenses while actually travelling,
which is where the request came from.
The redirect sits on '/' because that is what a shortcut, a bookmark and
the installed PWA all launch (manifest start_url). /dashboard keeps
working as itself - redirecting there too would leave no way back to it.
The preference is mirrored into localStorage, the way the language and
the theme snapshot already are, so the first paint can decide without
waiting on GET /api/settings. That keeps the default launch exactly as
fast as before: only someone who asked for the trip pays for the lookup,
and if it fails or they have no trip they get the dashboard. Right after
login the store already holds the real settings, so those win there.
Login stops hardcoding /dashboard as its fallback and hands the decision
to the same place, so there is one answer to "where does TREK open".
/trips/:id?tab=finanzplan opens the planner straight on that tab. Read in
the state initializer, not an effect, so the planner never paints the plan
view and swaps a frame later. The param is consumed on arrival; the
session memory keeps the choice across a reload.
Two things the tab machinery got away with while nothing ever started on a
tab other than plan:
- the guard evicted anything not in TRIP_TABS, but enabledAddons is an
optimistic guess until the addon feed answers, and collab is guessed off
- so a requested collab tab was thrown away a render before we knew the
addon was on. It now waits for the feed, like it already did for plugins.
- the lazy loads hang off handleTabChange, which the tab we *start* on
never goes through. Opening on Files showed an empty list, and that was
already true for a tab restored from a previous visit.
Tab ids move to constants/tripTabs so the settings picker can name them
the way the tab bar does instead of keeping a second list.
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.
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.
* 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.
* 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.
Every page imported its mobile screen statically and chose while rendering, so
each route chunk carried both trees and the viewport only decided which half
stayed dark. Worse, because ten route chunks shared the mobile tree, rollup
hoisted it into the entry chunk — so every desktop user downloaded the whole
mobile shell before seeing a single page.
The branch now decides the chunk. A phone never fetches the desktop planner, a
desktop never the mobile shell.
entry chunk 1,243 -> 581 kB (gzip 349 -> 177)
TripPlannerPage 914 -> 340 kB
VacayPage 361 -> 57 kB
AdminPage 413 -> 225 kB
SettingsPage 259 -> 155 kB
Splitting inside each page instead was measured and rejected: it leaves the
desktop code in the same module as the switch, so a phone would still download
it and then wait for a second, serial request on top — 914 kB and a waterfall
against 550 kB in parallel.
The pass-through wrapper in each page stays deliberately. check-page-pattern
scans from the default export to the next top-level function and rejects hooks
there; making the desktop component the default export fails it immediately on
dashboard, planner and atlas.
RouteFallback gets a mobile face. It renders above MobileShell, so the --m-*
tokens do not resolve for it and the desktop spinner on bg-surface would be a
foreign white sheet in front of the mobile screen.
The five scattered viewport spot checks are replaced by one contract covering
all eleven routes, which is where the switch now lives.
* 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.
* feat(client): retry a failed page chunk once before giving up
A dynamic import fails for two different reasons, and only one of them is
worth a second attempt. If the file is gone because a deploy happened while
the tab was open, nothing but a reload helps and reloadOnceForChunk already
owns that path. If the fetch itself went wrong — hotel wifi, an aborted
request, a service worker handing back a truncated entry — one retry fixes
it.
The retry cannot reuse the original specifier, since Vite rewrites it into a
hashed asset URL at build time; the only usable URL is the one the browser
names in the failure message, and a fresh query on it misses both the HTTP
cache and the Workbox precache.
This is also where a healthy load is acknowledged. Clearing the reload marker
used to sit in main.tsx right after render(), which runs before React commits
and long before any chunk is fetched, so the marker was gone before it was
ever needed.
* perf(client): load the page chunks on demand
Every one of the twenty pages was a static import, so the entry chunk carried
the planner, the journal, the atlas and the vacation planner for anyone who
only opened the dashboard. They now load through lazyWithRetry; the entry
chunk drops from 5,183 kB to 1,243 kB (gzip 1,300 -> 349).
LoginPage stays static on purpose: anyone logged out lands there, and every
other route redirects there first.
The Suspense boundary sits above <Routes> rather than inside ProtectedRoute
so it stays mounted across navigations. react-router runs location updates
inside a transition, which keeps the current page on screen while the next
chunk arrives instead of flashing a spinner on every jump.
The precache is unchanged by design: workbox picks up the new chunks, so the
install size stays where it was (107 -> 178 entries, +31 KiB) and every route
remains available offline. This buys first paint, not install size.
* 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.
* 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.
* 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.
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.
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.
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
- 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
- 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
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.
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.
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