* Rich formatting and colours for notes
Asked for in discussion #1629: notes should carry formatting, links and
colour, so a warning reads differently from a recommendation.
Notes have rendered Markdown for a long time. The problem was that the
only way to write it was a bare textarea, so unless you already knew the
syntax there was nothing to tell you it existed. Both note fields get a
formatting bar - bold, italic, strikethrough, code, link, lists, quote -
which edits Markdown rather than replacing it with a rich-text model, so
the stored value stays plain text that the PDF, the plugin API and every
existing note keep reading unchanged.
Links now open in their own tab. A bare anchor navigates the planner
away, which loses the open day and the map, and without rel=noopener the
opened page can navigate this one - notes are content other trip members
wrote.
Day notes get a colour from a short shared palette. It tints the card,
the border and the icon circle, and it reaches the PDF as the spine and
rule rather than a fill. The dialog is two columns now, with a live
preview of the card beside the picker, because a row of swatches says
nothing about what a tinted note looks like next to a place.
On the phone the thirty-two icon grid was most of the screen, so it is
folded behind the chosen icon, which doubles as the colour preview. The
detail field there got the same formatting bar and the same 2000
character ceiling as the desktop one - they write the same column.
* Fix the tests that pinned the old note caps, and rework the note actions
The e2e suite builds its own day_notes table by hand rather than going
through schema.ts, so it never got the new column and every note insert
came back 500. Three more tests wrote down the 250 character body cap
and the old create() arity.
While in there: the edit and delete buttons on a note card were a 10px
icon in a 14px box, side by side, and they took width away from the text
on every note whether you were hovering or not. They are stacked in a
capsule now, 30px targets with their own hover states, floating over the
right edge of the card only while the pointer is on it. The text keeps
the full width the rest of the time.
* Centre the day badge when there is no weather to stack under it
The badge is pinned to the top of the row because with weather it is a
tall stack that has to start there. Without a location there is no
weather block, so all that is left is a 26px circle floating above the
day title.
* Render the place subtitle in the places list, and drop the form hint
The row in the places sidebar printed the description raw while the same
line in the day plan renders it, so a place written with the formatting
bar read as _underscores_ in one list and italics in the other. Still
clamped to one line - it is a list entry, not the inspector.
The "Markdown supported" line under the notes field goes: the bar above
it already says so.
Requested in discussion #1658: an expense carries an amount and a
category, and everything else about it ends up crammed into the title.
The column was already there. It just was not available, because the
costs UI stores an itemized receipt in it as TICKETJSON:{...}, so an
expense split by receipt could never carry a note - and saving one any
other way wrote note=null, which quietly wiped whatever had been typed
into the budget table. The receipt gets its own column and note goes
back to being text. Old clients that still send the blob as a note are
routed to the right column instead of clobbering the note.
On desktop the row now has fixed columns, so the amount does not drift
left when one row has a note and the next does not. The category moved
to the card edge as a tab like the mobile card does, which is what frees
the line the note sits on. Collapsed it is one line, and the same
element grows in place when opened.
The dialog is two columns now: what the expense was on the left, how it
is shared on the right, grouped into panels the way the trip dialog
does it.
On the phone the eleven category pills became a dropdown, which is where
the room for the note field came from, and a note on a card unfolds when
you tap it.
* 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.
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.
Sixteen of the thirty-four entries in the body-contract allow list were the
JourneyController, and a strict schema already existed for six of them. That
is the trap: eight of these handlers validate their own body and answer with
their own text - 'Title is required', 'trip_id required', 'provider and
asset_id required', 'entry_date is required', 'user_id required' - and those
bodies are pinned by e2e and integration cases. Adopting the strict schemas
would have the pipe answer first, with a different body. That is a
client-visible change wearing the costume of a refactor.
So the schemas follow the memories doctrine instead: loose objects, and every
field the handler type-checks itself is unknown here. They describe the shape
that already works, which is what the boot gate needs, and every hand-rolled
400 keeps answering. The spec inverts accordingly - it now asserts that each
schema ACCEPTS what the handler still rejects, which is the property that
makes the claim checkable.
Two of them are optional at the top level, and that is not decoration: a
multipart upload with files and no text fields arrives with no body at all,
and JOURNEY-INT-045 pins 'No files uploaded' for it. Without the optional the
pipe answered 'body: Invalid input: expected object' instead. The suite
caught it.
Backup joins them - its schema was already a passthrough, because the service
normalises the body itself.
Allow list: 34 entries down to 17. Tags and the two OAuth entries stay for
now; tags reads @Body('name') per property, which is a signature change
rather than a contract one.
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.
NOTICE knew about geoBoundaries, OSM and OurAirports but not about Wikimedia,
Wikipedia or Google Places, all of which TREK has been calling for a while.
Also fixes two dead paths in the place contract - it still pointed at
server/src/routes/places.ts and services/placeService.ts, both long gone.
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.
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.
GET /api/trips/active answers with the trip a user means by "my trip"
right now: the one running today, else the next one starting, else the
most recently started. Archived trips never qualify.
That order is the dashboard hero's (sortTrips), on purpose - the startup
redirect this is for has to land on the same trip the dashboard features,
or the two would disagree about which trip is yours.
Kept apart from list() because it runs on a first paint: four columns of
one row instead of every trip with its per-trip day and place counts.
The last nine memories entries in the body-contract allow list are gone: three
each on the immich, synology and unified controllers. They read @Body() as
Record<string, unknown> and coerced by hand.
The schemas are deliberately permissive and loose, and that is the point rather
than a shortcut. Paging arrives as `2` or `'2'` because the controller does
`Number(page) || 1`; synology_skip_ssl arrives as a boolean or the string
'true'; auto_upload is accepted in any shape because only a real boolean is
acted on. A schema that described the ideal body instead of the real one would
turn working clients into 400s, and parity outranks tightening. Narrowing them
is a separate, client-visible change.
Loose objects for the same reason: a strict object strips unknown keys, and
`selections[]` goes straight to the services as `Selection[]` - stripping there
would be a silent data loss the day a provider adds a field.
This unblocks be-memories-fold, which could not move a controller whose body had
no contract to move with it.
* 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.
* 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.
- 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
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
The atlas counted every trip as a visit, so a country you had merely booked
a flight to was stamped the same as one you came back from.
tripVisitStatus buckets a trip by its dates: already started (or running)
counts as visited, starting in the future is planned, and a trip with no
dates at all is an idea that must not reach the visit stats. The comparison
is UTC, matching the dashboard and journey lifecycles.
Requested in discussion #1048.
Twelve schemas covering every AdminController body, replacing the four that were
imported nowhere. Deliberately permissive wherever AdminService already owns a
bespoke 400 ('Invalid role', 'Name is required', 'Username, email and password
are required'), so those errors survive byte-identically: role is z.string() not
an enum, template name and the user-create fields stay optional. savePermissions
keeps z.unknown() values so unknown levels still land in the 200 response's
skipped list; addon config and the notification-preference matrix stay records so
per-addon keys and plugin channels are not stripped; devTestNotification's inApp
accepts the object form the dev panel actually sends.
'Abflug'/'Abflugzeit' are flight-specific, but the reservations.departureDate/
departureTime keys are shared across all transport types in TransportModal
(flight and train waypoints). Switched to 'Abreise'/'Abreisezeit', which read
naturally for any mode of transport.
notifications.dto.ts wraps the @trek/shared request schemas via createZodDto
and types all five @Body() params; the five NotificationsController entries
leave the body-contract allow-list (ratchet gate enforced). Contract catch:
the client sends server/token: null on test-ntfy to mean 'use the saved
value', so testNtfyRequestSchema gains .nullable() there (spec pinned). The
inline response-enum and url-type checks die — malformed bodies now get the
pipe's standard envelope; valid bodies behave byte-identically. Two
integration cases pinning pre-ratchet tolerances (flat notify_* preferences
body, body-less test-smtp POST) update to the current wire contract.
DTO ratchet for the auth domain (collections sibling-commit precedent):
- shared/src/auth/auth.schema.ts gains the six missing request schemas —
mapsKey (nullable: the client clears with an explicit null), apiKeys and
settings (per-key partials, nullable key values), appSettings (open
record — the server's ADMIN_SETTINGS_KEYS allow-list and its bespoke
lockout/self-MFA 400s stay service-owned), mfaDisable, resourceToken
(the 'Invalid purpose' 400 stays service-owned) — plus spec cases.
- New nest/auth/auth.dto.ts createZodDto wrappers; all 14 raw @Body()
params typed (9 AuthController + 5 AuthPublicController), validated by
the global ZodValidationPipe.
- All 14 auth entries removed from body-contract-allow-list.ts
(PasskeyController's 4 stay until passkeyService migrates).
- auth.e2e mirrors the production pipe and pins the { error: 'field: ...' }
envelope on a shapeless login body; auth.controller.ts drops its
duplicated avatarDir const in favour of the auth.helpers export.
Fold the 1029-line services/placeService.ts into the DI-native PlacesService:
the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list
importers become methods over the injected DatabaseService, byte-identical
statements, COALESCE semantics and the If-Match conflict protocol (#1135).
The pure half — frozen XML parsers, the KMZ unpacker, the dedup predicates,
the Google hex-id parsers, reclaimPhotoCache — moves to places.helpers.ts
(maps.helpers precedent).
The 10-tool mcp/tools/places.ts registrar plus the trek://trips/{tripId}/places
resource move onto the decorator registry as places.mcp.ts. search_place travels
with them because its gate is places:read, not the read-only geo group, and now
injects MapsService. TripsService, DaysMcp, BookingImportService and the plugin
RPC host (PluginHostDepsFactory's 21st constructor dep) all inject PlacesService,
so the domain needs no bridge at all. The two assignments.bridge imports that
places.mcp.ts keeps are a deliberate cycle break: AssignmentsModule imports
DaysModule and DaysModule now imports PlacesModule for days.mcp.ts's place
creation, so injecting AssignmentsService there would close
DaysModule -> PlacesModule -> AssignmentsModule -> DaysModule. Same seam
reservations.mcp.ts uses, same trade trips.bridge.ts documents.
Also folds services/placeEnrichment.ts in — its DB/websocket/Maps half becomes
PlacesService methods over the injected DatabaseService/RealtimeService/
MapsService, its pure match selector joins places.helpers.ts — which retires
nest/maps/maps.bridge.ts with its last consumer.
Adopts places.dto.ts for the seven grandfathered body contracts and clears every
PlacesController entry from body-contract-allow-list.ts. Two consequences worth
knowing: the bespoke 400s 'Place name is required', 'ids must be an array of
numbers' and 'URL is required' are now the ZodValidationPipe envelope, and
because a pipe runs before the handler a malformed body 400s ahead of the
trip-access 404 it used to follow — the same trade the todo and trips migrations
took. placeBulkUpdateRequestSchema.ids drops .min(1) so the endpoint's
empty-list short-circuit stays reachable.
Fixes four defects the relocation had faithfully carried, each with a
failing-first regression test:
- create dropped lat/lng of exactly 0, so a place on the equator or the prime
meridian lost its coordinates (`x || null` cannot tell absent from zero).
- duration_minutes: 0 was unsettable — replaced by the 60-minute default on
create, and read as "absent" by COALESCE on update.
- the journey delete hook fired on unscoped ids: DELETE /:id, POST /bulk-delete
and the delete_place MCP tool could detach another trip's journey entries for
an id they then refused. All three scope first via the new
PlacesService.scopedIds, mirroring the guard PluginHostDepsFactory already had.
bulk_delete_places also moves its hook ahead of the DELETE —
journey_entries.source_place_id is ON DELETE SET NULL, so running it afterwards
left the entries as orphans.
- the place search treated % and _ as LIKE wildcards; a bare % returned the whole
trip. The three clauses now carry ESCAPE with the term escaped.
Plus hardening on the Google list fetch: the response is capped at 8 MB
(declared content-length and post-read length, the transit.service precedent)
and its JSON.parse is guarded, so a malformed provider payload produces the
existing 'Invalid list data received from Google Maps' 400 instead of throwing.
Quirks deliberately preserved: the non-COALESCE route_color (#776),
currency/transport_mode staying unclearable, importGpx returning null for a file
that parses but yields no usable geometry, update skipping the If-Match check
when the stored updated_at is null, and every string-valued `x || null`.
Tests move with their case IDs: placeService.test.ts and the wrapper suite merge
into unit/nest/places.service.test.ts, kmzUnpack into places.helpers.test.ts,
placeEnrichment into places.enrichment.test.ts. The places e2e converts to the
DI-native pattern (real SQL against temp-db DDL), tools-places.test.ts is kept
and extended so it exercises the decorator path through the test registry, and
the plugin-host and booking-import path mocks become constructor stubs.
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.
Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
assignment:moved requires newDayId again; assignment:reordered and
day:deleted drop their drift-union variants now that every emitter
sends the canonical shape.
TREK_WS_EVENTS: 65 trip-scoped + 29 user-scoped events with Zod
payload schemas transcribed from the emitting call sites; derived
name unions and payload types; plugin: namespace reserved as the
escape hatch. 8 payload-drift shapes modeled as unions and flagged.
Imported tracks all render in the same blue: the GPX/KML importers never
assign a category, so category_color is null for every one of them and
several walks in the same area are impossible to tell apart.
Adds a nullable route_color on places, kept out of the COALESCE group in
updatePlace so an explicit null can reset a track back to inheriting its
category colour. The hex is validated in the Nest controller rather than
in the shared contract: the update body is an open record on purpose, and
tightening it would mean removing PlacesController.update from the
ratchet-only body-contract allow-list.
The importers now hand each new track a colour of its own, picked from
the palette entries the trip is not already using. That happens in the
Nest service over the injected DatabaseService — the colour assignment is
new logic, and new logic does not belong in the legacy place service (see
server/src/nest/README.md). The field itself is only threaded through the
existing update/create statements, which the proportionality rule covers.
Also fixes duplicateTrip, whose explicit column list has been silently
dropping route_geometry all along, so a duplicated trip lost its tracks.
ReservationsController's 4 grandfathered @Body() params now validate through
the global ZodValidationPipe (createZodDto over the existing @trek/shared
schemas); their allow-list entries are removed (the boot gate rejects stale
entries). The bespoke 400 bodies (Title is required / positions must be an
array / user_ids must be an array) become the pipe's field envelope, and
non-numeric traveler ids now 400 instead of being silently dropped.
reservationPositionsRequestSchema is loosened to the real wire contract:
day_plan_position is optional (the legacy route never validated position
items and an absent value binds NULL) — pinned by RESV-006.
AccommodationsController stays grandfathered until the dayService migration.