Compare commits

...

240 Commits

Author SHA1 Message Date
Maurice e225548c79 docs(tiles): add the 4.3.0 release banner
The 4.3.0 notes drop the plates and the tile index for the Keep a Changelog layout. This banner is the one image left, at the top. Scaled from 5292 to 2400 px wide so the body does not pull 4.7 MB.
2026-09-19 11:43:56 +02:00
Maurice e5bda89faa docs(tiles): give the index heading its own weight 2026-09-17 21:39:20 +02:00
Maurice 815020dd58 docs(tiles): tighten the space between index rows 2026-09-17 21:38:51 +02:00
Maurice ebfa266ee0 docs(tiles): flag the three headliners instead of enlarging them 2026-09-17 21:37:40 +02:00
Maurice f450208db4 docs(tiles): give the release's three headliners their own weight 2026-09-17 21:35:23 +02:00
Maurice d1d7857363 docs(tiles): close the gap under the index band 2026-09-17 21:33:42 +02:00
Maurice e7a5acbd68 docs(tiles): a header band over the release index 2026-09-17 21:32:25 +02:00
Maurice e02cc7ecf7 docs(tiles): carry the gap inside each index plate 2026-09-17 21:29:28 +02:00
Maurice d3219af062 docs(tiles): the release index as twelve small plates 2026-09-17 18:06:07 +02:00
Maurice ae2d522890 docs(tiles): a transparent spacer, so the release index can hold its columns 2026-09-17 18:03:45 +02:00
Maurice b513dde39e docs(tiles): plates for the road trip and Dawarich sections 2026-09-17 17:50:35 +02:00
Maurice dee2603d42 docs(tiles): give both plates a lit glass rim 2026-09-17 17:36:23 +02:00
Maurice 633a2b38a4 docs(tiles): two plates for 4.3.0, on the product's own dark palette 2026-09-17 17:35:01 +02:00
Maurice 2a66665935 docs(tiles): draw the road trip day as a rail, not a list
The card beside the headline was three places stacked, which is what the
planner already had before this addon existed. It is the rail now: one
line down the drive, a filled node for a place you are going to and a
hollow one for a halt on the way, the time and the distance on the line
between them, and the one thing that has to be seen before it is read,
a stop past the daily limit, in the only colour on the plate that is not
its own pair.
2026-09-13 01:16:04 +02:00
Maurice d2bc4cc297 docs(tiles): fill the Dawarich list out to its neighbour's height
Two stays beside a tall pane left a gap under the card that read as a
rendering fault rather than as space. Four fill it, and the list stretches
instead of centring.

The fourth figure was the nine scopes an API key can be narrowed to, and
the section of the release that explained them is gone; it says what the
page still says, that the addon is off until an admin turns it on.
2026-09-13 01:08:29 +02:00
Maurice 4c65b17a72 docs(tiles): let the hero lede run, and give the bars title room
The lede stopped at a width picked by hand, which broke the line early
and left a wedge of empty pane beside it; it runs to the pane's own
padding now, the same gap it keeps on the left. The bars title sits on
the ground rather than in a pane, so it takes more air under it than two
panes take between them.
2026-09-13 01:07:15 +02:00
Maurice 2e1db584bd docs(tiles): the bars title comes off the pane
It sat in a glass panel of its own with nothing else in it, which read as
a card that had lost its contents. It is the plate's heading now, on the
ground at the plate's own margin, the way the eyebrows sit elsewhere.
The winning row drops "alternating"; the bar beside it already says what
the two sources add up to.
2026-09-13 01:05:42 +02:00
Maurice 003c5826ab docs(tiles): the bars plate carries its title alone
The paragraph under it explained the corpus, which the sentence beside
the image on the release page already does. The plate is the claim and
the four bars behind it.
2026-09-13 01:03:43 +02:00
Maurice dff79089ab docs(tiles): road trip plates, and edits to the Places API set
Three plates for the road trip addon in a third palette, violet and
teal, so the page's three subjects tell themselves apart before a word is
read: the drive as one chain with a day's own card beside it, what it
finds along the road against what a list of days could offer, and the
four ways to send the drive where you want it.

The Places API set loses two headings that repeated the sentence above
them, and the half of each comparison that is history is drained of
colour and dimmed, by brightness rather than opacity so the pane keeps
hiding the route behind it. "Index" is gone everywhere in favour of TREK
API, and the bars drop the index-alone row.
2026-09-13 01:03:03 +02:00
Maurice f9dabccf19 docs(tiles): warm palette for the Places API half
Both halves of the release page were built on the same blue and green,
which are Dawarich's own two brand colours and mean something on its
plates. On the Places API ones they meant nothing and made the page read
as one long subject.

The aurora and the accents are four tokens now, overridden per plate and
nothing else with them: the Places API set is amber and rose, Dawarich
keeps the cool pair it takes from its mark.
2026-09-13 00:52:41 +02:00
Maurice f84564e2e9 docs(tiles): use the real trek API lockup in the hero
The mark was assembled here out of the plain wordmark and a hand-built
API chip, which is not the lockup: the official one has no leading glyph
and its own badge geometry. It ships as one file now, black on
transparent as delivered, inverted on the plate because that is the
dark-background variant.
2026-09-13 00:49:57 +02:00
Maurice 105cce5deb docs(tiles): put the API mark level with the headline
The plate is 16:5 and its right half was empty ground while the lockup
stacked above a two-line title and pushed the lede down the card. The
mark sits on the far side of the headline now, at the size that space
deserves.

The fit check was reading .stage, which is `height: 100%` and therefore
always reported the plate's own height; it measures the first and last
block instead, which is how the hero turns out to have been carrying 94px
of empty ground under it.
2026-09-13 00:45:46 +02:00
Maurice be5004e282 docs(tiles): one spacing step for every plate, and fit the heights
The gaps were whatever each plate happened to pick: 16 here, 22 there,
and a `margin-top: auto` that pushed a tile row flush against the card
above it. They are one token now, used between the stacked blocks,
between the tiles, and between a card and the numbers under it.

Every plate is also as tall as its content instead of a guess, so nothing
is clipped and no plate publishes a band of empty ground. The Places API
hero drops the release pill and carries its API badge at the size the
lockup is now, and one subtext in the comparison is a line shorter so it
stops wrapping.
2026-09-13 00:43:36 +02:00
Maurice b37d66a4c1 docs(tiles): redraw the Places API plates to match, and glass the rims
The release page carried two visual languages: flat cards for the Places
API and glass for Dawarich, one above the other. All five plates are the
same build now, from the same stylesheet, with the wording, the figures,
the bars and the row order of the originals left exactly as they were.

Each plate also carries the rim it was missing, a lit 1px edge and a
sheen along the top, so the image reads as a pane rather than as a dark
rectangle with rounded corners.
2026-09-13 00:39:56 +02:00
Maurice c620b44a7b docs(tiles): give both marks the same badge in the hero lockup
TREK's wordmark sat bare beside a chipped Dawarich, which read as one
brand hosting another rather than two meeting. Both are in the same glass
pill now, at a size the lockup deserves, with the multiplication sign
white between them instead of grey.
2026-09-13 00:30:36 +02:00
Maurice c58818e8f8 docs(tiles): round the Dawarich plates off and lift the small type
The plates are rounded now and carry transparency outside the radius, so
they sit on the release page as cards rather than as two black rectangles
with square corners against it.

The secondary lines were the same grey as the labels around them and read
as switched off at the size GitHub renders these; they are close to white
now. The second plate loses its heading, which repeated the sentence
above it, and the route line is one line instead of two.
2026-09-13 00:28:50 +02:00
Maurice 5e1b588791 docs(tiles): redraw the Dawarich plates in glass, and cut to two
The flat cards read as a spec sheet. Both plates now sit on the same
aurora, mesh and recorded route the app's own glass panels imply, with
rounded frosted panes over it; the hero carries a real From Dawarich row
so the first thing anybody sees is the product rather than a claim.

Two plates, not three: the third said in pictures what the text beside it
already says in words.
2026-09-13 00:21:49 +02:00
Maurice b4ea23203d docs(tiles): release art for the Dawarich integration
Three plates in the same dark palette and type scale as the Places API
set, built from the tokens in client/src/index.css, the TREK wordmark and
Dawarich's own mark: the lockup and the four numbers that matter, the
line between what stays in Dawarich and what reaches TREK as a question,
and the four places it turns up once it is connected.
2026-09-13 00:16:48 +02:00
Maurice b9d0f6df4b docs(tiles): close the gap the corpus note left behind
Blanking the two lines left a band of empty plate between the title and the
first bar, which read as a missing element rather than as breathing room.
The band is cut out instead, so the plate is 56px shorter and the spacing
above the bars matches the spacing below them.
2026-09-08 22:12:12 +02:00
Maurice aa482be840 docs(tiles): drop the corpus note under the top-5 title
The two grey lines explained where the 128 places came from. In a release
note that reads as a caveat before the numbers rather than context after
them, and the corpus is already described in the text around the image.
2026-09-08 22:10:57 +02:00
Maurice afa0dd0e33 docs(tiles): the two figures that got better read as such
72.7% and 58 ms take the success token, and their boxes a trace of it.
Only those two: tinting the licence line underneath as well would turn a
statement of fact into a claim.
2026-09-07 22:30:14 +02:00
Maurice 4be4c4ea11 docs(tiles): drop the eyebrow over the bars
The caption under the heading already says where the numbers come from.
2026-09-07 22:26:04 +02:00
Maurice 5db38e82f2 docs(tiles): a glass edge on every plate
The same treatment the repo's own tiles carry: a hairline ring at 12%
white, a brighter line along the top where the light would catch, and a
diagonal sheen falling off from the top left. The radius matches the
mask the PNG is cut with, so the ring follows the corners instead of
stopping at them.
2026-09-07 22:24:28 +02:00
Maurice 8cbea7ad47 docs(tiles): drop the eyebrow over the comparison
The heading above it in the post says the same thing.
2026-09-07 22:20:51 +02:00
Maurice 49c598a384 docs(tiles): close the gap under the hero subtext
The pills were pushed to the bottom of the plate, which left a hand's
width of nothing between them and the sentence they belong to. The plate
is as tall as its content now.
2026-09-07 21:21:27 +02:00
Maurice 1dd1ad838d docs(tiles): the hero field carries itself
The dashed drive and its five lit stops were one idea too many over a
field that already reads. Just the field now, and the light behind the
mark.
2026-09-07 20:59:27 +02:00
Maurice 7f5352efe5 docs(tiles): drop the route line from the hero
The field carries the plate on its own; the dashed drive across it was
one idea too many.
2026-09-07 20:58:46 +02:00
Maurice 96f5d9b587 docs(tiles): give the hero a field of places to stand in
73.6 million is a number nobody can picture, so the plate shows a field
instead of only naming one: 520 seeded dots weighted to the right so the
headline keeps a quiet ground, five of them lit and strung together like
a drive across it. The mark lifts off that on its own light.
2026-09-07 20:48:21 +02:00
Maurice 049cc50fda docs(tiles): both plates end on the same line
min-height only set a floor, and the right card's two wrapping notes
pushed past it, so the pair ended seventeen pixels apart. Both fill the
grid row now.
2026-09-07 20:39:06 +02:00
Maurice cd6f3b9d2c docs(tiles): figures in boxes, the old column read as old, rounded plates
The past column now carries a faint diagonal hatch and one step less
contrast on every word in it, so the eye sorts the two sides before it
reads either. Every figure sits in its own box, the three closing
numbers are centred, and the caption above them lost the sentence that
pushed it onto a third line. All four plates get rounded corners with
real transparency, so they sit on either GitHub theme.
2026-09-07 20:36:39 +02:00
Maurice 784237a02b docs(tiles): the wordmark beside the title, and the figures as badges
The subtext ran on across three ragged lines and the four figures sat in
a bare grid under a rule. They are pills now, and the sentence breaks
where it means to. The mark moves out of the corner into a plate beside
the headline, where it is large enough to read as the subject.
2026-09-07 20:26:02 +02:00
Maurice f9333d51a6 docs(tiles): three bars, and the arrow on the panel row
The chart carried a fourth bar for the index on its own, which is a step
in the reasoning rather than an option anybody can pick. Nominatim, what
ships, and Google is the comparison a reader is actually making.

The arrow sat in a column that included the metrics under the panels, so
it centred below them. It sits on the panel row now.
2026-09-07 20:24:41 +02:00
Maurice 802ac51904 docs(tiles): release art for the TREK Places API
Four plates in the TREK dark palette, built from the real tokens in
client/src/index.css, the wordmark component and the Lucide set the app
already uses: the hero, the before-and-after against Nominatim and
Overpass, the settings card at poster size, and the measured top-5 bars.
2026-09-07 20:20:52 +02:00
Maurice f1120be3c1 test(roadtrip): pin the via contracts
The schemas had no spec of their own. The two things worth pinning are the
ones the feature rests on: the anchor is a position and never an id, so a
fraction or a negative is not a leg of anything; and the batch route's
track has three states, because absent and null mean different things to a
day that already follows a line.
2026-09-07 18:36:54 +02:00
Maurice d3aad4c41e test(roadtrip): cover the planner half the mode switch unlocks
The road trip handlers in useTripPlanner are unreachable until the addon is
on for the instance and the mode is on for the trip, so the existing suite
never entered them: 360 of the file's new lines and branches had never run.

Turns both switches on and drives what the rail, the corridor panel and the
map call. The cases that matter are the ones where a via has to be
re-anchored: a via records which stop it follows by position, so a reorder,
a move between days or an insert that forgets the correction silently
re-shapes the drive.
2026-09-07 18:36:54 +02:00
Maurice 91801b10c0 test(roadtrip): cover the five client pieces that had no test
Five files, about 570 lines, that shipped without one. The cases are chosen for
what a later change could break quietly rather than for the line count.

useRouteAlternatives carries the decision the feature rests on: when a leg
already has vias, the road actually being driven is NOT among what the router
offers for the two bare endpoints, because it was asked a different question. It
is fetched separately and put at the top — without that, opening the picker on a
leg you have reshaped shows three roads, none of which is the one you are on and
no way back to it. Also pinned: a via that barely moves the route produces one
entry rather than two, a second ask abandons the answer still in flight, and only
driving, walking and cycling ever reach the router.

RoadtripStayModal writes `places.duration_minutes`, which the schedule has read
for years and nothing could write. The case that matters is that clearing sends a
zero and never a null: the update statement folds a null into "leave it alone",
so a null could give a stop a stay and never take one away. The clock cases pin
the thing a slider is for over a text box, which is watching the departure move.

StopKindPicker is a portal, so it gets the cases a portal makes easy to get
wrong: nothing without an anchor, outside-click and Escape both closing, the
anchor itself not counting as outside, and picking the kind a stop already has
taking it back off rather than setting it twice.

RoadtripCategoryPicker stays open on a click, which is the whole reason it is
multi-select, and names the kinds rather than counting them.

RoadtripAlternativesBar reports each offer by its own index rather than by its
position — a position drifts the moment the driven road is prepended — and shows
loading, failed and "only one way" as three different answers instead of an
empty list.
2026-09-07 18:36:54 +02:00
Maurice b2d9b25bb6 fix(roadtrip): say which filter emptied the list, and stop typing a limit racing itself
Four small things, and one that was not this branch's fault but is now its
neighbour.

The driving limits saved on every keystroke. Typing "180" sent three settings
writes carrying 1, 18 and 180, unordered and concurrent over HTTP/2, so
whichever the server committed last won: the number that decides every
over-budget warning could quietly end up a tenth of what was typed, and only
show it after the next reload. Typed locally now, committed on blur or Enter,
and a value that did not actually change writes nothing.

The empty-result message always blamed the name box. Four controls narrow that
list and only one of them is the name, so picking a section with no hits
produced `Nothing on the way matches ""` — empty quotes naming a filter the
reader never set. The count line above it was gated the same way, so the section
case lost its "x of y" as well. Both follow whatever actually narrowed the list.

The check-out dropdown printed a bare ISO date, the only place in the planner
that did; it goes through the same helper as the rail beside it.

Switching an addon off left open MCP sessions holding its write tools. A `when:`
gate runs once per session, at attach time, so the tools stay registered and
callable while the REST half answers 404 for the same user in the same moment —
and the list that decides whether to tear those sessions down had drifted:
airtrail and collections gate tools and were missing from it, which this addon
would have inherited. The list now sits beside ADDON_IDS and a parity test reads
the gates out of the source and holds it there. Read out of the source rather
than out of a runtime registry on purpose: a runtime set only holds the gates
whose modules the caller happened to import, so it would pass in a suite that
imports none — which is the shape of test that hid this in the first place.

RoadtripLimitsCard gets the test it never had, including the one case that
matters: three keystrokes are not three writes.
2026-09-07 18:36:54 +02:00
Maurice 44d52ab215 fix(roadtrip): stop one gesture writing twice, and one search running too early
Five things, all of them a click or a drag doing something the traveller did not
ask for.

The GL via handle committed a move on every pointerup. Any button started the
drag and there was no movement threshold, so a plain click wrote the via to
wherever inside the 17px handle the pointer happened to be — at a low zoom that
is kilometres, and the write re-routes the whole trip. A right-click ran the
drag AND the delete, so one gesture sent two writes; on the platforms where
contextmenu comes first, the move landed on an id that no longer existed and
reported a failure for a deletion that had worked. Leaflet gets this right for
free from `dragend`, which is why the two renderers behaved differently for the
same gesture. Primary button only now, with a threshold, and pointercancel puts
the pin back instead of committing — the browser fires it when the captured
element is torn out of the DOM, which is exactly what a delete does.

With the alternatives picker open, one click on the route fired both handlers.
Layer registrations are independent in MapLibre, and every alternative starts
and ends at the same two stops — when the leg already carries vias the current
road is itself one of the offers, so the overlap is the whole leg. The click
dropped a via AND chose a different road: two writes and two trip reloads for a
gesture made once.

The corridor search could be started before the day had routed. The corridor is
then built from the straight line between the stops rather than the roads
driven, and the routing answer landing a second later changes the line, which
clears the search mid-flight and drops every result — no error, no partial
result, just the idle empty state again. Routing runs one day at a time about a
second apart, so the window is wide open on a long trip.

Leaving road trip mode left the alternatives drawn on the map. The switch is in
the sidebar and reachable while the bar is open, and the overlay depends only on
the picker, so pale blue routes with drive-time pills stayed on an ordinary
planner map with no road trip UI left to dismiss them from.

Clearing a leg's vias swallowed its failure and closed the picker anyway. Every
neighbouring write in that hook reports one. The leg still carried its via and
still routed the old way, so the traveller believed they had undone the detour.

Walking and cycling legs were counted as driving. A day may legitimately mix
modes — splitIntoRuns exists for that — and a two hour hike was reported as
"over the longest drive allowed", its minutes went into the day's driving time
and its kilometres onto the tank, where a ferry crossing could trip a range
warning outright. A leg with no mode still counts, because every leg was a drive
before the field existed.

The road-trip domain also says out loud that it is online-only, which the repo
asks for rather than leaving it to be discovered. A via anchors by POSITION, so a
queued write replayed after somebody else moved a stop would re-pin the drive
onto a leg nobody chose, silently and hours later; the offline story needs the
anchor to be an assignment id first. Until then the surfaces disable the gesture
instead of accepting one and dropping it.

Six theme-lint markers move onto the line they exempt. The linter suppresses per
line, so a marker in the comment block above suppressed nothing and the six
road-signage colours the authors documented as intentional were counted anyway.
2026-09-07 18:36:54 +02:00
Maurice 239856f35c fix(settings): make the self-hosted routing engine an instance setting
The field was rendered in every non-managed user's own Map settings and written
to their personal settings row. The CSP is built from something else: bootstrap
reads the ADMIN default and puts that origin into `connect-src`. So filling the
field in was the one path the product offered, and it could not work — the route
calculator happily built its URLs against the typed origin and the browser
refused every one of them with a bare TypeError, not a status, so no fallback and
no retry could see it. Day-plan routes, transport legs, the mobile day route,
Studio's road route and the whole road-trip rail stopped routing, app-wide, with
a generic "route could not be calculated". Nothing in the UI could repair it
except emptying the field again, and there was no admin field to set the value
that would have made the origin allowed.

The repo has decided this shape once already: `llm_base_url` is admin-only
because it names where the server sends its own requests and only whoever runs
the instance can judge what is reachable. This is the same class for a different
reason, so it joins the same guard, which is now named for what it actually
covers. Clearing stays open to everyone, so a row written before the rule cleans
itself up rather than blocking the save.

The field moves to the admin's default-user settings, next to the tile URL and
the Carto key, where a value takes effect.
2026-09-07 18:36:54 +02:00
Maurice 65be8db122 test(roadtrip): cover the surface the domain shipped without
The domain arrived with 557 lines, a service test, and nothing else. No
controller test, no e2e, and no MCP test — so the guard chain, the trip scoping
on every route, the cross-trip track check and the day_edit parity were all
unasserted, and the whole domain sat in the 80 catch-all where it could shed
twenty points against the headroom of the domains beside it.

The e2e is the one that earns its place. `@RequireAddon` is metadata and does
nothing on its own, and AddonGuard is not global, so the ordering the
controller's own comment calls load-bearing is exactly the kind of thing a later
change removes without noticing — it had already happened once. Deleting the
guard now turns three cases red, which I checked by deleting it.

The MCP tools had never executed at all: the shared harness seeds the addon
disabled, so `when: roadtripAddonOn` kept all five from attaching. They now run
against the same checks the REST routes make, including the demo account and a
member without day_edit, because the repo requires the two surfaces to move
together.

Two client modules that had no test get one: the overlay builder, where two
decisions live that are not cosmetic — which offered road is drawn as the one you
are on, and that a label hangs where its own route is unlike every other, rather
than at a midpoint that can fall on a stretch two routes share — and the service
marker, where the thing worth pinning is that an ordinary place gets nothing
back, and that the anchor arithmetic includes the border.

The domain carries its own coverage floor now instead of hiding in the catch-all.
Pinned under the measured figure on purpose; the exact ratchet gets regenerated
with the repo's own script after a full run.

Along the way, roadtrip_vias and roadtrip_day_tracks join the reset list every
other domain's tables are on. Without that a via written by one case was still
there for the next one, which is how the first version of the MCP suite passed
an assertion it should have failed.
2026-09-07 18:36:54 +02:00
Maurice 41e256bf0f feat(roadtrip): answer the corridor search from the place index
Search along the route went to Overpass and only to Overpass, on the one path
where that hurts most: one search fans out over sixteen boxes, and each of those
races four public mirrors. The index answers the map's explore pills in
milliseconds where those mirrors take seconds, and the corridor was the reason
the index exists.

Two independent things kept it out. The corridor sends its categories as a
comma-separated list, and the index branch looked the whole string up as one key,
so it missed every time. And the four kinds a drive needs, fuel, charging, rest
areas and campsites, had no Overture terms mapped at all. They do now:
gas_station and fueling_station, ev_charging_station, rest_stop, and campground
with rv_park.

The branch is list-aware the way searchOverpassPois already was, and it labels
each hit with the category that produced it rather than with the list that was
asked for, because the client colours and groups its markers by that field and
"fuel,charging" is not a category. It answers all-or-nothing: a category the
index has no terms for sends the whole query to Overpass, since answering the
half it knows would silently drop the rest while the panel still reported a
finished search. The caller's limit is passed through instead of a hardcoded 50,
so a mixed search gets the same per-category budget the Overpass path spends.

The other outbound path of this addon changes with it. Importing a shared Google
route geocoded its waypoints by calling searchNominatim directly, which walked
past geocodeQuery and its index lookup. Through geocodeQuery an index hit costs
no throttle slot at all, and what the index does not know still falls through on
the background lane.

A place answered from the index also carries its chain and that chain's Wikidata
item, so a branch gets its logo on the pin here exactly as it does on the
Overpass path. Sockets stay an OSM field: a charging station answered from the
index reports "not stated" rather than claiming it offers nothing.
2026-09-07 18:36:54 +02:00
Maurice 1e1dd9b814 fix(roadtrip): keep the shaping a traveller did, through every path that moves a stop
A via records which stop it follows by POSITION, so anything that renumbers a
day's stops moves the ground under it. The road-trip rail corrects for that;
almost nothing else did.

reanchor() never renumbered `sequence`. It is allocated per leg and starts at 0
in each, and re-anchoring MERGES two legs whenever the stop between them goes
away — so leg A's 0,1 ended up beside leg B's 0,1, and everything that draws the
route orders by sequence. The drive ran A0, B0, A1, B1: forward, back, and
forward again. Persisted, surviving a reload, and unrepairable except by deleting
the vias. The old anchor is read before the updates and used as the primary sort
key, so the vias of the earlier leg stay ahead of the ones that merged into it.

Reordering the only two stops of a day deleted every via on it. A reorder is
modelled as remove-then-insert, and the removal half refuses to treat a two-stop
day as still having a drive — right when a stop really goes away, catastrophic
when nothing was removed. Two stops is exactly the shape "follow this track"
produces, up to nine vias on that one leg, gone to a single drag with no prompt
and no undo.

Removing a stop, reordering within a day, and assigning a place mid-day never
re-anchored at all, though the helpers exist and three other paths use them. The
detour then sat on a leg the traveller never chose, or on a leg that no longer
existed and vanished — until a later stop brought it back bending a road nobody
asked for. Reordering hands a whole new ordering rather than one move, so the
anchors there follow the stop they were pinned behind instead of arithmetic on
the index, which is correct for any permutation.

Duplicating a trip dropped all of it: `stop_type` was missing from the place
insert, and neither via nor day-track table was copied. The duplicate looked
complete and quietly drove somewhere else.

A failed read of the via list emptied it. That read runs after every write and
feeds the routing key, so a 502, a dropped connection or a tab going offline
re-routed the whole trip along the roads the traveller had steered away from,
with nothing said and nothing retried — indistinguishable from having deleted
them. Only a 404 or 403 empties now; anything else keeps the last known list and
says it may be stale.
2026-09-07 18:36:54 +02:00
Maurice c1f7587704 fix(roadtrip): a freshly placed via lost its first drag
Placing one triggers a re-route, the re-route lands about a second later, and
the render it causes rebuilt every via handle — which is exactly when somebody
reaches for the dot they just made. On the GL renderer that rebuild replaces
the DOM element outright, and a pointerdown belongs to the element it happened
on, so the drag went to an element that no longer existed. The second attempt
worked because by then nothing was pending, and an older via never showed it
because nothing was re-routing.

Two causes, both identity: the vias arrive as a fresh object on every reload
even when the rows came back identical, and `onMoveVia` is a fresh closure on
every render of the planner. Both were in the effect's dependencies.

The list is now keyed by what a handle is built from — id and position — so it
only changes when a via does, and the callbacks are read through a ref. A via
that genuinely moved still gets a new list, which is what redraws it where it
now is; the test pins both halves, because holding the element still must not
mean holding it in the wrong place.

Leaflet's markers had the same churn without the same symptom: a fresh
`position` array called `setLatLng` on every marker on every render, and a
fresh `eventHandlers` object unbound and rebound every listener. Same fix.
2026-09-07 18:36:54 +02:00
Maurice 676c82a308 feat(roadtrip): remember which track a day follows
The vias already made the day follow the road; nothing said which road. After a
reload the badge could only say "shaped by hand", and re-fitting after the stops
changed meant finding the track again by eye.

One row per day, written in the same transaction as the chain it describes: a
day claiming to follow a road whose vias never landed would be worse than a day
claiming nothing. Three states rather than two, which is why the field is an
object and not an id — absent leaves whatever the day followed alone, null says
it follows nothing, an object records the road it now takes. A bare nullable id
could not tell "do not touch this" apart from "forget it", and a hand-dragged
via on the same day must not wipe the label.

The foreign key is the point: delete the imported track and the label cascades
with it, so no day is left claiming to follow a line nobody can see. The vias
stay, because they are the drive.

Both write paths check that the place is a track of this trip before it becomes
a label — permission alone would let another trip's place id, or an ordinary
place, become a name that can never be drawn.

Also clears three reliability findings the scanner raised on this branch: two
default sorts given an explicit comparator, and a trailing-slash regex the
scanner is right to call backtracking, walked instead.
2026-09-07 18:36:54 +02:00
Maurice 6fca238440 feat(places): import the stops of a shared Google Maps route
Pasting a /maps/dir/ link into the list-import box answered "could not
extract list ID", which is the whole of the complaint: people share a drive
far more often than they share a list, and it is the same gesture — somebody
pressed Share in Google Maps. Which screen they were on is the URL's business,
so the box takes both and the controller decides.

No API key and no call to Google for the link itself: the stops are in the URL.
Three shapes arrive — the path form with its data blob, the documented
?api=1&origin= form, and a short link that is one of those after a redirect —
and a stop the link spells out in coordinates needs no geocoder at all. One
that is only a name is geocoded, keeping the name from the link rather than
the one the geocoder answers with: somebody who typed a nickname into Google
should not find a street address on their trip.

A name nobody can place is left out and counted rather than failing the
import, because a route of six stops with five findable is five stops more
than the traveller had. Coordinates are read as 1d/2d, longitude before
latitude, which is the reverse of a place link and the one way to put Berlin
in Somalia. A blob that does not line up with the stops is ignored entirely:
half a link read confidently is worse than one read as names.
2026-09-07 18:36:54 +02:00
Maurice 761ec4e66f feat(roadtrip): let a day drive along an imported track
An imported GPX track was a line drawn beside the day: the map showed the
scenic route while the day kept driving the motorway, so the distance, the
drive time and every warning belonged to a road nobody was taking. Norway
publishes its scenic routes that way, and so does every touring club worth
importing from.

A track becomes a route the day takes by refinement, not by conversion. Route
through what we have, find the point of the track the road misses by the most,
make that point a via, go again. Each round roughly halves the worst miss,
because the new via lands in the middle of what the last one left unfollowed,
so a scenic route usually fits inside ten vias — and the loop stops when the
road is close enough rather than after a fixed count. Every point of the track
would route exactly and take a hundred requests; two would route in seconds
and follow nothing.

The day is routed whole per round, not leg by leg: it is a single request
already, and per-leg would multiply every round by the number of legs against
a host that answers one request a second. The track is oriented to the day
first, because half of Norway's routes are published southbound, and the legs
it covers are cleared before the new chain lands, so applying a track twice
does not stack.

Offered from the day's own badges, and only once something has been imported.
Tinted while the day carries vias: the rail draws none of them, so that badge
is the only place a drive shaped by hand differs from one the router picked.
2026-09-07 18:36:54 +02:00
Maurice 620f4c6ecd fix(roadtrip): let the section filter shorten instead of overflowing
A nowrap label reports its whole width as its min-content contribution, so the
place-name select sat at its default `min-width: auto` and refused to shrink —
pushing the ± kilometres control past the panel's right edge instead of
ellipsing the name.

The menu that select opens now grows to its options rather than inheriting the
narrowed trigger, bounded by the panel it sits in so it never hangs over the
map: a list of identical shortened prefixes is not a list you can pick from.
2026-09-07 18:36:54 +02:00
Maurice 36ac5de84c feat(roadtrip): narrow the corridor to one stretch of the drive
A seven-hundred-kilometre day turns up seventy hits spread over the whole of
it, and the traveller wanting a break near Berlin has to read all seventy and
work out from "245 km along the route" which ones are anywhere near. That is
the wrong way round: you decide roughly where to stop, and then look at what is
there.

Picking a point narrows the list to what lies within 25, 50 or 100 km of it.
This is a subtraction rather than a second search, because the stops and the
hits are already measured along the same thinned line.

The midpoints of the legs are offered alongside the stops, and they are the
point of the feature: a break on a five-hour drive belongs in the middle of the
drive, not at either end. Asking only about stops would answer "what is near
Berlin" when the question is "what is near the halfway mark".

The chosen point is stored as which anchor it is rather than as a distance.
alongKm is measured against a line thinned with a tolerance derived from the
corridor width, so a kilometre figure kept here would quietly drift when the
width goes from 5 to 10; resolved through the stops it lands on the same place
again. It also names its day, so switching days cannot apply yesterday's break
to today's drive.
2026-09-07 18:36:54 +02:00
Maurice 061259b3d9 feat(maps): pass through what OSM knows about a charging station
The corridor query has always ended in `out center tags`, and the projection
has always kept six keys out of the whole tag set. Everything an electric car
needs to know was in the answer and went in the bin: which plug families the
station has, how many of each, at what power, how many vehicles fit, and whether
it charges. Reading them costs no extra request, no key and no second query.

The filters treat "not stated" as its own answer, which is the only honest way
to build them: OSM states a socket type on roughly a third of charging stations
and a power on fewer still, so a power filter keeps every station that says
nothing rather than reading silence as too slow. The plug dropdown offers only
the families this search actually turned up, because CHAdeMO in a list for a
corridor with three Type 2 posts on it is a filter that can only return nothing.

Socket names stay as they are printed on the plug. "Type 2" is not a string
anyone translates, and the lowercase tag key is not what is written on the
charger.

The tool description follows, as parity requires, including the coverage caveat
— a model that reads a missing power as "no fast charging here" would be wrong
about most of the map.
2026-09-07 18:36:54 +02:00
Maurice fc74991a15 feat(roadtrip): turn a place into a stop on the way from its own number
An ordinary place could not become a fuel stop without going through the full
form, and the number in the rail is exactly what changes when it does: a service
stop has none. So the number is the control. Click the 3, pick the pump, and the
3 becomes an orange disc while everything below renumbers itself. The disc of a
service stop opens the same picker the other way, with a row to make it an
ordinary destination again.

Six coloured discs rather than six lines of text: at that size the colour and
the shape are the label, and anyone who has seen one petrol stop on the map
knows the orange pump before reading anything.

Centred on the column rather than on the number, which sits hard against the
left edge and would hang the panel half over the map. The click stops at the
disc, so picking a kind never also selects the place underneath.

Also in this change, from the same round: the corridor search offers hotels, a
hit somebody could sleep at opens the popup in an overnight mode with a check-out
day instead of a dwell, and booking one writes the place, its day and the row in
day_accommodations together. A hotel keeps its number, because sleeping there
ends the day rather than interrupting the drive, which is why the category list
and the stop-kind list are now two lists instead of one.
2026-09-07 18:36:54 +02:00
Maurice 49f97af3fb fix(roadtrip): the range figure keeps counting until something actually refuels
Reported off a real day: Berlin said 245 km since the last fill-up and Sanssouci
said 121, as if the tank had been filled in between. It had not. The budget was
being zeroed at every warning, so the first figure was right and every one after
it was the length of one leg rather than the distance on the tank.

A warning is not a fill-up. Only a fuel or charging stop resets it now, which is
what makes putting one in the answer rather than a formality: the same day reads
125, 370, 519 without one, and collapses to a single mark with one.

Nothing is flagged on a fuel stop either. Reaching a petrol station on an empty
tank is the plan working, and a warning there would sit on the one stop that
answers it.

Also: the stop number sits level with the middle of its row rather than at the
top, the way the corridor list already places its badges. A number pinned to the
first line drifts off as soon as a stop carries a stay and a walk under its name.
2026-09-07 18:36:54 +02:00
Maurice 4215db2908 refactor(roadtrip): the limits as badges, and a quieter empty state
The three figures under the button were one run-on string joined by dots, which
says nothing about which number is which. Each takes the badge shell the rail
already uses, with its own icon: a clock, a calendar, a pump. The icon tile in
front of the title goes, because it was eating exactly the width the three
badges need to sit on one line.

Distances there go through formatDistance, so an imperial traveller reads miles
on the button and types miles in the dialog while storage stays kilometres.

EmptyState gains a compact variant. A sidebar state sits under its own controls,
where a line at page size competes with them; at the caption tier it reads as
what it is, a note about why the list below is blank.
2026-09-07 18:36:54 +02:00
Maurice 95c173a8a8 fix(roadtrip): put a driving finding on the stop it arrives at, not the one it leaves
Reported from a real trip: Hamburg, A1, HEM, Berlin. HEM to Berlin is 244.7 km,
and the badge on Berlin said 121 — which is the length of the NEXT leg, Berlin
to Sanssouci. The finding was anchored to the stop the leg departs from while
its wording ("since the last fill-up") is a running total, so the number never
matched what anyone adds up off the drive bands above it.

Both findings are true on arrival, so that is where they belong. The same trip
now reads 125 at HEM (21.5 + 103.4) and 245 at Berlin, which is exactly what you
get counting the bands.

A service stop carries them too. A charging halt is a stop like any other as far
as the tank is concerned, and HEM was the very stop whose figure went missing.

The corridor's empty state gets the mascot the rest of TREK uses: searching,
idle, and a confused look when a filter left nothing standing, so the picture
says which of the three it is before the sentence is read.
2026-09-07 18:36:54 +02:00
Maurice 282e3c6b13 refactor(roadtrip): TREK's own tooltips everywhere, and a visible edge on the warning badge
Eight native title attributes across the rail and the corridor panel, which
render as the browser's own grey box after its own delay and ignore every
appearance setting. All of them are the shared Tooltip now, the same one the
rest of the app uses.

The warning badge had its border in the soft tint of the warning colour, which
at sixteen pixels disappears into the card and left it as the only badge in the
row without a shell. It takes the warning colour itself.

Two tests moved with it: a tooltip only enters the DOM on hover, so what they
pin now is the visible difference between a pinned and a computed arrival —
weight and ink — rather than an attribute that no longer exists.
2026-09-07 18:36:54 +02:00
Maurice ea9de65550 feat(places): make the kind of stop editable, and enforce it on the way in
Three holes, one seam. Leaving the corridor popup by "more details" dropped the
kind and the dwell it had just worked out, so a fuel stop silently became a
numbered destination that counted in every total, in the badge and in the PDF.
Once set, the kind could never be changed back through the web UI at all. And
the REST route did not validate the field: both request schemas are open
objects, so stop_type: 'banana' was written straight to the column, where
isServiceStopType read it as false and the stop counted as a destination again.

The form now carries the field, so the popup's answer survives the trip through
it, an ordinary place can become a service stop, and a wrong kind can be taken
off. Editing keeps stop_type and deliberately leaves duration_minutes out: how
long a stay takes belongs to the rail's own dialog, and sending it from here
would overwrite what was set there.

The schemas gain the one field rather than closing: roughly 190 call sites post
whatever their form holds, and a closed object would reject all of them at once.
The doc comment claiming the list was already enforced is corrected — it was
describing an intention.

The two driving findings move into the stop's badge row in warning colours,
beside the stay and the walk from the road. All three say what this stop costs,
and one row reads as one answer rather than as notes stacked underneath.
2026-09-07 18:36:54 +02:00
Maurice e79a2591a1 refactor(roadtrip): put the driving limits behind a button, and the walk beside the stay
Two things the column was getting wrong. The limits sat open as a card, three
rows of a narrow sidebar for something set once on the first day of planning
and left alone after; they are behind a proper full-width button now, with what
is currently set riding on the button so the form does not have to be open to
read it. And the walk from the road was its own line under the stay, where it
looked like an unrelated note; it wears the stay's two-part badge and sits
beside it, because both answer the same question about a stop — one in time,
one in a walk.
2026-09-07 18:36:54 +02:00
Maurice dfb038d90f feat(roadtrip): say when a drive is longer than you drive
Roel and gfRally asked for three things: how far the car goes on a tank, how
long you are willing to sit behind the wheel in one go, and how much driving
belongs in a day. They are one feature. All three are a threshold, a walk over
the legs, and a finding.

Two things they deliberately are not. They are durations and distances rather
than times of day, because with no stop pinned to a clock the cascade produces
no times at all, for any stop, and a limit on an hour would simply never fire.
And the day limit is called driving time, not travel time, because the figure
it is measured against is exactly that: the badge beside it, which counts no
time spent at stops and no leg to a hotel. The card says so out loud rather
than leaving it to be discovered.

The range budget carries across midnight, since a tank does not empty
overnight, and resets after a finding: without that, 1800 km on a 600 km range
flags every leg after the first overrun. Only fuel and charging reset it, never
the other four service kinds — a two-hour lunch fills nothing, and nobody here
knows whether the restaurant has a charger in its car park.

Kilometres in storage, the traveller's own unit on screen. Skipping that round
trip means an imperial user types 400 meaning miles, 400 km is stored, and the
warnings arrive a third too early from then on.

No stop is suggested and no vehicle is modelled. OSM does not know what car
this is and neither do we, so the range is a number you type. Furkot does the
same, for the same reason.
2026-09-07 18:36:54 +02:00
Maurice 56266138c6 feat(roadtrip): show the halts a routing plugin puts on the drive
A plugin may already route a day in road trip mode, and the charging stops it
answered with were thrown away: the map was handed an empty list and the rail
never looked. That made "build it as a plugin" — the answer given to charging
prices, to ABRP and to the AFIR data — untrue in the one mode where it matters.

Each halt is measured along the run once and filed on the leg it is actually
driven on, using the leg lengths the router reported rather than by projecting
the stops too: leg distances are exact by construction, while two stops close
together project onto each other's stretch and would put a charge the wrong
side of a town.

The rail row is deliberately the poorest of the four. No number, no editable
stay, no arrival time, a hollow disc: the halt belongs to the provider, not to
the traveller, and everything filled on that rail is something they put there.
Nor is it ever written back — a plugin halt stored as a via would go out as a
waypoint on the next run, and the plugin would plan around its own charging
stop.

No time on the row for a second reason: the SDK contract folds the stop into
the leg duration, so the arrivals already account for it. Adding dwellSeconds
to the schedule would push every arrival after a charge back by the charging
time.
2026-09-07 18:36:54 +02:00
Maurice 11f4c740e1 feat(roadtrip): draw the last bit to a place the road does not reach
A router snaps every waypoint to the nearest road before it starts, and OSRM
does it with no distance limit at all. A viewpoint above a valley, a farmhouse
down a track, a marina at the end of a pier: the drive to it really begins
somewhere else, and the line said nothing about that. It either stopped short
for no visible reason or appeared to cut across open ground.

OSRM reports the snap position and its distance in every single answer, and
this client has thrown both away since the first route was drawn. Reading them
costs no request and no key.

The gap is drawn as a dashed spur in the route's own blue, over the route
rather than under it, because it is the one piece of the line that is not
driving. Thirty metres is the floor, which is about a dual carriageway and its
verge, so a place pinned off an aerial photo does not earn a dash. From a
quarter of a kilometre the rail prints the distance too, because that is where
"the car park is over there" turns into luggage, a gate, or a track a hire car
should not be on.

Vias are deliberately left out: a via is a shape handle, not a destination, and
a dash hanging off one reads as a fault.
2026-09-07 18:36:54 +02:00
Maurice d6eac772aa fix(maps): ask a router that refuses exclude only once
Both public hosts TREK ships with reject the parameter with HTTP 400: their car
profile is built without excludable classes, and only a self-hosted OSRM built
through the MLD pipeline has them. Every long leg was therefore paying two extra
requests, against a host that allows one per second, to be refused twice.

The first real attempt is the probe. Only a 400 is remembered, because only a
400 is about the host: a 429 is a rate limit, an abort is the user moving on,
and a NoRoute means this leg has no way round the motorway while the next one
might. Kept per base URL, so pointing the instance at an own router that does
support it starts asking again without a reload.

The comment above the fallback said none of this and read as a working feature,
which is why "only one sensible way to drive this" keeps appearing on legs that
plainly have another road.
2026-09-07 18:36:54 +02:00
Maurice 67163e7224 feat(roadtrip): lay a whole chain of via points in one write
The single-via route is right for a detour dragged by hand and wrong for
anything that derives its anchors from a line. Pinning a day onto a recorded
track or a signed scenic road means a couple of dozen points, and one request
each re-routes the entire trip once per point, spaced by the routing host's own
rate limit — twenty anchors would spend half a minute drawing routes nobody
asked to see.

replace_legs names the legs to clear rather than taking a flag for the day, and
that is the whole difference: a traveller who bent two legs by hand and then
adopts a scenic road for a third has not asked to lose the two.

The tools follow in the same change, as parity requires. Undoing a detour picks
this up too, where the vias of one leg were deleted one request at a time.
2026-09-07 18:36:54 +02:00
Maurice ba7bd1dd91 refactor(roadtrip): settle the routed-leg shapes before three features fill them
Three separate features write into the same two places here: the leg merge and
the final memo. The access lines want the snapped waypoints, plugin routing
wants the via points it was given, and the driving limits want findings that
belong to a leg rather than to a stop. Built one after another, the same two
lines get rewritten three times.

So the shapes land once, empty. RoutedLeg gains snapped and vias, RoadtripDay
gains driveWarnings and dayWarning, RoadtripRoutes gains accessLines, and the
memo becomes a walk rather than a map, because a range budget carried past
midnight needs somewhere to live between two days.

Schedule stays exactly as it was. Its own test compares the empty result with
toEqual, which checks both directions, so a third field there would fail it —
and a finding about the driving does not belong to a stop anyway.
2026-09-07 18:36:54 +02:00
Maurice c82a8f899f refactor(roadtrip): one table for the six stop kinds, not nine lists
The kinds were written out in nine places: the enum in @trek/shared, the
service list and the colours in the model, the corridor's category keys, the
map's road-trip categories, the popup's own table, two lookups in the rail, one
in the map markers and one in the corridor panel. Each carried a comment asking
the next person to keep the others in step, and it came apart anyway.

A rest area was a parking sign in the popup, on the map pill and in the panel,
and a coffee cup on the rail disc and the map marker, for the same stop. The
parking sign wins: the colour has been documented since it was written as "the
blue every parking sign in Europe is printed in".

stopKinds.ts holds one row per kind and everything else reads from it. It sits
beside the model rather than inside it because an icon is a React component and
roadtripModel.ts is deliberately React-free so the arithmetic can be tested on
its own; the colours and the service list stay there and are read from here.
Two flags rather than one, because they are not the same question: a hotel is
something the corridor can find, but sleeping somewhere ends the day rather
than interrupting the drive.

Seven tests pin the table against the two lists that cannot import it, so the
next kind added in one place and forgotten in another fails a test instead of
quietly counting as a destination.
2026-09-07 18:36:54 +02:00
Maurice 23bda0e1e1 test(roadtrip): register the via tools in the MCP test registry
The registry the MCP tests build had every other domain and not this one, so
the four via tools were absent from every assertion that walks it. A test that
asked whether they were reachable, or gated, would have passed without them
ever being there.
2026-09-07 18:36:54 +02:00
Maurice d1a13058a6 fix(roadtrip): make the addon gate on the REST routes actually run
@RequireAddon has sat on the controller since the domain was written and did
nothing: it is metadata, AddonGuard was not in the chain, and it is not one of
the three global guards either. The addon ships disabled (seeds sets enabled 0),
so on any instance that never switched it on, all six via routes answered
anyway. Two doc comments in this repo already describe the behaviour this
commit creates rather than the one it replaces.

AddonGuard leads the chain, the way reservation-import and collections do it,
because a disabled addon owes an anonymous caller a 404 rather than a 401.
2026-09-07 18:36:54 +02:00
Maurice 25249749ad test(planner): give the page fixture the road trip state it reads
The page reaches into routeAlternatives.open, roadtripRoutes.lines and
roadtripVias.byDay before it ever asks whether road trip mode is on, so a
fixture without them took all 47 wiring cases down with "Cannot read properties
of undefined". The mode stays off, which is the state every assertion here is
written against.
2026-09-07 18:36:54 +02:00
Maurice 9a0a6f8c3b fix(settings): the routing endpoint belongs to the operator
routing_base_url arrived on this branch and was assigned to neither managed
set, which MANAGED-KEYS-001 exists to catch. It is locked, alongside the other
infrastructure endpoints: the host it names is folded into the CSP once at boot
(routingCspOrigins), so a customer who repoints it lands in a policy that does
not know their router and the browser blocks the requests with nothing to
catch. MANAGED-KEYS-005 pins the list verbatim and is updated with it.
2026-09-07 18:36:54 +02:00
Maurice 7829043a00 test(app-config): pin the overpass budget against its own constant
The client-side budget moved to twenty-five seconds when the query timeout it
has to outlast was written down as a constant, and this assertion stayed on the
old twelve. Pointing it at OVERPASS_TIMEOUT_DEFAULT_MS is what the test was
after in the first place: the budget must be longer than the timeout the query
carries, and a literal here is exactly how the two came apart.
2026-09-07 18:36:54 +02:00
Maurice 7b5c8c1fae test(places): teach the e2e tables the stop_type column
Three e2e suites build their own trimmed places table with a plain CREATE TABLE
rather than going through the schema and the migrations, so the column this
branch added never reached them. Every assignment, day and place case that
touched a place failed on "no such column: stop_type", thirteen in total, and
none of it showed because the checks do not run on a draft.
2026-09-07 18:36:54 +02:00
Maurice eebe9a52e1 fix(places): let a stop type be corrected over MCP, not only set
create_place learned stop_type when the column arrived; the three tools that
could change a place did not. Over MCP the kind was therefore writable exactly
once and never afterwards: a place saved as fuel fell out of every stop count,
wore the wrong disc for good, and could only be put right through the REST API.

update_place and bulk_update_places take it nullable, which is how the service
already reads "turn this back into an ordinary place" (an explicit null rather
than COALESCE). create_and_assign_place takes it the way create_place does.

The description also named four kinds where the enum holds six, so the model
had no way to reach restaurant or sights.
2026-09-07 18:36:54 +02:00
Maurice f1fa155899 fix(roadtrip): gate the via tools on the addon, the way the routes are
The controller carries @RequireAddon(ROADTRIP), the four MCP tools carried
nothing. On an instance that left the addon off, REST answered 404 while
list_route_vias, add_route_via, reanchor_route_vias and remove_route_via kept
reading and writing, which is exactly the parity the repo refuses to break.

Same shape as budget: an addonGate constant, AddonsService held publicly so
the module-level gate can reach it, AddonsModule in the imports.
2026-09-07 18:36:54 +02:00
Maurice 4f57c1ee13 fix(pdf): stop printing a rental's counter twice with an arrow between
The car branch prints the endpoint chain whenever it holds two entries, which
an ordinary rental always does: picked up and returned at the same desk. The
printout then reads "Munich Airport -> Munich Airport", where before there was
no car branch at all and the line simply stayed empty.

Consecutive repeats now collapse and a chain naming one place only is dropped.
A genuine round trip keeps both ends, because Dresden sits between them.
2026-09-07 18:36:54 +02:00
Maurice 3d252ac994 fix(places): keep the hour a new place has always defaulted to
The road trip rail reads `duration_minutes` as a stay, and a place created
without one claimed an hour nobody had entered. The branch answered that by
binding NULL instead, which is right for the rail and wrong for everything
else: the column declares DEFAULT 60, three tests assert it, and the comment
on one of them says so in as many words. That default reaches every place in
TREK, not only the ones on a drive.

So the hour stays, and the road trip solves its own problem where it arises.
A stop added from the corridor search already brings its own figure with it
(RoadtripStopPopup's STOP_KINDS: ten minutes for fuel, twenty for a rest area,
thirty for a charger), and saveStopDraft passes it through, so the kinds that
would be misread as an hour never take the default in the first place.
2026-09-07 18:36:54 +02:00
Maurice e11ae6f056 feat(roadtrip): shape the drive — vias, alternatives and stops along the way
The road-trip addon's second half: the parts that let a planned drive be
argued with rather than only read.

Reshaping. A via records which stop it follows by position, and that position
is resolved fresh every time the day is routed — so inserting, reordering or
removing a stop moved the ground under every via after it, and the drive
silently snapped back to the road it had been steered away from while the
handle still sat on the map where it was left. The correction is computed
against the day as it stands and written with the same change, in one
transaction: a half-applied re-anchoring mixes two numbering schemes in one
day. The one judgement that needs geometry — which half of a split leg a via
belongs to when a stop lands in the middle of it — is measured on the road
actually driven, so a fuel stop added to a bent leg keeps the bend on the side
it belongs to.

Alternatives. OSRM answers most long legs with exactly one route, which read as
"there is no other way" when a perfectly good slower road existed. When it
offers nothing, the same drive is asked for again without the motorway, and
failing that without the tolls — one question at a time, stopping as soon as
one lands. The offer says what it is rather than how much slower.

Stops along the way. Everything the corridor search can find is a pause, not a
destination: fuel, charging, rest areas, campsites, food and sights all sit
inside the leg they were found on, carry their own colour and icon, and are
left out of every stop count. That colour is defined once and read by the rail,
the corridor list, the map pin and the picker — and it replaced the chains'
logos, which turned a corridor full of petrol stations into a row of
advertisements and left the brands with no logo on file looking like something
else entirely.

How long a stop takes was readable in the rail and writable nowhere; new places
were given an invented hour that nobody had entered. It is now set from the
rail, cleared as a zero because the update statement folds a null into "leave
it alone", and new places start with nothing.

Both columns are rebuilt on one type scale, anchored to the day plan's own
sizes so switching tabs does not change the size of the text beside it. The
rail reads as trip totals, day cards and a chain of stops with the drives
between them; the corridor column as what to look for, how the search is
getting on, and what it found. Six category pills over three lines became one
dropdown, which is the space the results wanted.

Also: the offered routes are framed on the map when the picker opens (`fitKey`
carries no coordinates and each renderer reads it as "the selected day"), road
trip mode does not cluster at all so the whole drive stays readable zoomed out,
and the drive-time labels finally render in the app's own typeface instead of
Leaflet's Helvetica. All of it in both renderers.
2026-09-07 18:36:54 +02:00
Maurice 1db55f734c fix(maps): wait longer than the query we ask Overpass to run
The query carries `[timeout:20]`, so the mirror is allowed twenty seconds of
work on it — and the fetch around it was aborted after twelve. A mirror under
load that would have answered at fifteen never got the chance, and the caller
saw a failed area.

That is what makes a corridor search along a long drive report half its
stretches as unsearchable: the stretches were being searched, we just hung up.
Both numbers now come from one constant, the client budget is the query's own
plus room to hand the answer back, and a test holds the relationship rather
than the two literals.
2026-09-07 18:36:54 +02:00
Maurice b339ef3f04 fix(days): keep a place's stop type on both assignment loaders
Three read paths built the same nested place object by hand, and two of them
left `stop_type` out of it while their SQL was already selecting the column.
The effect was visible: a petrol station added along a route drew correctly
for a moment from the optimistic write, then the server's answer replaced it
with a place that had no kind, and the road-trip rail redrew it as an ordinary
numbered destination.

`getAssignmentWithPlace` now goes through `formatAssignmentWithPlace` like the
list path already did, which removes the third copy rather than adding the
missing field to it. The two queries in the days service gain the column, and
its hand-built shape gains the field it cannot yet be spared.
2026-09-07 18:36:54 +02:00
Maurice f1eadd670d test(roadtrip): read the last mock call without Array#at
The client's lib is ES2020, where Array#at does not exist. Local typecheck ran
before the file was added and CI caught it.
2026-09-07 18:36:54 +02:00
Maurice d6955f20a6 test(roadtrip): cover the routing, corridor and panel behaviour
The addon shipped with only its two pure modules under test, which left the parts
that actually talk to the network — and the panel a user reads the answer from —
resting on nothing.

Covers the routing hook (one request per travel-mode run, day default over trip
fallback, retry then give up, no re-fetch on a rename, abort on leave), the
corridor search (one query per box carrying every kind, the perpendicular filter,
dedupe, the retry pass before a stretch counts as unsearched), the corridor state
(roads driven over the line between stops, day follow-along) and both panels.

Two findings from writing them, fixed here:

- A dead straight leg is reduced to its two ends by the simplifier, and tiling
  walks point to point — so a 250 km motorway stretch produced a box at each end
  and nothing in between, while the search still reported itself complete. Steps
  longer than half a box are now split before tiling.
- projectOnSegment guarded a degenerate segment with `lenSq === 0`, which two
  ends that came through a chain of floating point arithmetic never hit.

Also splits the anchor resolution out of computeSchedule, which was three levels
of nesting for one question, and sorts the POI cache key with a comparator.
2026-09-07 18:36:54 +02:00
Maurice 6aa7884874 perf(maps): search a corridor with one query per box, not one per category
The road trip corridor asks for fuel, charging, rest areas and campsites over a
dozen boxes, and asking per category was four times the requests for the same
answer — slow, and four times the chance a shared Overpass mirror times out and
reports the stretch as unsearched.

`category` now takes a comma-separated list. Each OSM selector belongs to exactly
one category, so a hit is still labelled with the kind it answered, and the
result cap is shared out per category so one dense kind can't crowd the rest.
The MCP tool takes an array for the same reason.

Also puts the POI, plugin and route via-point pins on the map's render clock,
the same fix the planned-place pins already had: a library marker repositions on
every pointer sample while the canvas draws once a frame, so the suggestions
swam during a drag and snapped back on release.
2026-09-07 18:36:54 +02:00
Maurice 37179cb608 feat(maps): show a chain's own logo on its map pin
A corridor full of petrol stations was a row of identical coloured dots, and the one
thing that tells them apart is the brand. OSM carries brand:wikidata on most of them —
88% of the fuel stations in a Berlin box — Wikidata carries the logo as property P154,
and Commons serves it.

The bytes are proxied and cached rather than linked: one self-hosted instance asking
Wikimedia for a handful of logos is a very different egress profile from every
visitor's browser announcing which petrol stations they are looking at. The cache is in
memory because a GET must not write to the database, and there are only so many fuel
brands; a brand with no logo is remembered too, or every pan would ask again.

A logo-less or unreachable brand answers 204 and the pin keeps its category icon, so
nothing depends on Wikidata being reachable. Both renderers show it, since both read
the same category table.
2026-09-07 18:36:54 +02:00
Maurice 74258bd5d9 feat(roadtrip): give the corridor hits their own icons, and say when a stretch went unsearched
A petrol station and a campsite both came back as the same nameless grey dot, because
the road categories only existed on the server: the map falls back to grey for a
category it does not know. They now sit in the client's category table too — with the
pump, the bolt, the parking sign and the tent, in their own colours — which both map
renderers and the popup read from the same place.

They stay out of the explore pill's own list on purpose: that pill offers what a city
visit needs, and while road trip mode is on it is not on screen anyway.

The search also stayed quiet when only some boxes failed, which reads as "no fuel on
this stretch" when the truth is that nobody looked. A partial failure now says how many
stretches went unanswered.
2026-09-07 18:36:54 +02:00
Maurice 552bd61d11 fix(roadtrip): search along the roads driven, not the lines between stops
The corridor was built from the day's stops, so it followed straight lines across
country the car never drives. Between two cities that line runs through open fields
while the motorway swings wide of it, which is why every hit came back sitting on a
stop rather than on the way.

The days now carry the routed geometry, and the corridor is cut from that. Same trip,
same search: 192 hits spread over the whole 482 km — first fifty, middle, and the last
stretch — where before all of them reported kilometre zero.

Following real roads takes more boxes to cover the same drive, so they go out three at
a time instead of strictly one after another, and the budget rises from ten to sixteen.
The panel head loses the sentence explaining what the sections below already say, and
the day's distance and driving time, which the day list has been showing all along.
2026-09-07 18:36:54 +02:00
Maurice 647e972c8b feat(roadtrip): draw corridor hits on the map and build the day in one request
Three things the first cut got wrong.

Switching into road trip mode took its time: every leg was its own routing request,
paced a second apart so the shared hosts would answer at all. The router already
returns a leg per consecutive pair of the waypoints it is handed, so a day is now one
request — split only where the travel mode changes, because one request carries one
mode. Answers that come from the cache are no longer paced at all, which is what made
returning to the view feel broken.

A list of petrol stations is half an answer: which one is on your side of the road is
the other half. The corridor search therefore moved out of the panel into the planner,
and its hits are handed to the map like any other place suggestion — grey, because they
are not part of the trip until you add one. The explore pill stands down while the mode
is on: it draws into the same channel and answers the same question for a drive.

The panel itself was a stack of controls without an order. It now reads as header,
search, results: what is being searched (with the day's distance and driving time),
what is being looked for, how far off the route, then the hits grouped by kind with a
count — and a progress bar while the boxes are being worked through.
2026-09-07 18:36:54 +02:00
Maurice 07a4447bbd feat(roadtrip): find fuel, rest areas and sights along the drive
The road trip mode could show the route but not what sits beside it, which is
half of what planning a drive is about (#1797).

The right column becomes a corridor search: pick what you need, pick how far off
the route you will go for it, and the day's drive is searched box by box. The
boxes come from the route itself rather than the viewport, so the answer is
"on the way", not "on screen"; each hit reports how far off the route it lies
and how far into the drive it comes, and adding one opens the normal place form
so it lands on the day like any other stop.

Overpass is asked in plain bounding boxes rather than with a polyline filter,
because that is what the existing /api/maps/pois route accepts and what the
mirrors can answer; the geometry work is pure and tested. Long routes stop at ten
boxes and say so instead of quietly searching half the drive.

Fuel, charging, rest areas and campsites join the server's POI categories, so the
explore pill and the MCP search_pois tool gain them too. A car booking's stops now
also reach the PDF, which printed pick-up and return with everything in between
missing.
2026-09-07 18:36:54 +02:00
Maurice 3902e77991 feat(roadtrip): add the road trip addon with a drive-first plan view
The trip planner could show one day's route at a time and nothing across days,
so a road trip had to be pieced together in Google Maps (#1797, #435).

The addon adds a mode to the plan view rather than a tab of its own: the map
stays the workspace and gains the route over the whole trip, while the left rail
becomes one chain of stops with the driving distance and time between them and
arrival times that follow from them. A time pinned on a stop is kept and restarts
the chain, so a ferry or a museum ticket stays where the user put it.

Car bookings can finally carry the stops the schema has always allowed: the
endpoints table has held role/sequence for every transport type since #384, but
only flights and trains had an editor for them. The MCP tools already accepted
them, so this closes the gap the other way round.

Legs are fetched one at a time and spaced out — the public routing hosts answer a
whole trip's worth of requests with 429 — and the distance cap now looks at the
single leg instead of the sum, which is what dropped long drives entirely.
2026-09-07 18:36:54 +02:00
Maurice 7cdd61e7fb fix(maps): say which source answered, and stop the stubs driving the index
Two halves of one mistake, both found by the suite.

Letting each side of the merged search catch its own failure meant the index
could contribute nothing while the answer still called itself
"trek-places+openstreetmap". That label is not decoration: the search log writes
it into the corpus a candidate index gets scored against later, so a list that is
entirely OpenStreetMap must not be recorded as though the index had a hand in
it. The source now names what actually contributed.

And maps.service.test.ts drove the index for real. Its fetch stubs answer with
json() and no text(), which is not what the index client reads, so the branch was
entered and then failed on the stub — "uses Nominatim when user has no API key"
was really testing that a stub is incomplete. With the new circuit breaker those
failures also accumulated, and the breaker is process state, so it leaked into
whatever file ran next. trekPlacesSearch is stubbed there now, beside the nearby
stub that was already there for the same reason. The index paths keep their own
files.
2026-09-07 14:05:33 +02:00
Maurice ed5769fc89 fix(maps): name the index as the source, and stop it being asked twice
Attribution first, because this branch argues the point itself: the card names
the sources with the comment that naming them is a licence obligation rather
than decoration, and the copy beside it promises that every field says where it
came from. The POI path then stamped Overture rows as OpenStreetMap. Overture is
not OpenStreetMap — it carries OSM among other sources under other licences — so
that is a wrong attribution rather than a rounded one. The wire contract keeps
`source` an open string and nothing on the client switches on it, so saying
`trek-places` costs nothing. The MCP tool description no longer calls itself an
OpenStreetMap tool either, and says which source answered.

Adding one place cost two round trips to the index. The details lookup fetches
the place and its answer carries the description; toPlaceRecord dropped that
field, so the enrichment fetched the same place again a moment later to read it.
It is carried through now and read from what the caller already has, with the
second lookup left as the fallback for when there is nothing to read.

That description's source URL becomes an href on the client and was taken on
`typeof === 'string'` alone. It now goes through the same allow-list a place's
website goes through — the file already does this for menu_url, with the comment
explaining why — so anything but http(s) loses the link and keeps the text.

The instance token was hashed from raw APP_URL, which the shipped compose file
leaves commented out. Every default install therefore sent the same token, and a
token every caller shares is not a caller: one instance running a large import
would rate-limit all the others, who would fall back to Nominatim with nothing
to point at. getAppUrl() falls through to ALLOWED_ORIGINS and then to the port,
so unconfigured instances still differ.

Two client-side leaks. A pick made out of the offline cache was recorded into
the shadow corpus, where it moved the two figures the corpus exists to
measure — and offline is a setting, so the network is usually reachable and the
row landed. And the prefetch never re-checked the session after its request came
back: a logout in that window repoints the handle at the anonymous database, so
one person's trip area was written where the next person's offline search reads.

The cache path also ignored the abort signal, so an answer for a keystroke two
keystrokes old could overwrite a newer suggestion list. It rejects the way both
surfaces already know to swallow.

Left alone, and said in a comment rather than hidden: the index cannot localise.
The Overpass path picks name:<lang> and falls back to int_name; the service has
no language parameter at all, so a German reader exploring Tokyo gets the
Japanese primary names on this path.
2026-09-07 14:05:33 +02:00
Maurice b0aa7f8d95 fix(maps): stop asking an index that is not answering, and untie the place cache
Three things an install feels rather than reads.

A service that cannot be reached cost the full timeout on every call. Each call
site falls back to the old path, but pays 3.5 s first — so an instance behind a
firewall that drops rather than refuses, or one that simply has no route to the
service, pays it on every keystroke of autocomplete and on all three queries a
booking import runs per venue. Ten venues is over a hundred seconds of waiting
for an answer that was never coming. A process-wide breaker now fails instantly
after four consecutive failures and tries again a minute later, so an
unreachable index costs one timeout instead of one per request. A 404 does not
count against it, because "no such place" is an answer, and neither does an
oversized body, because the service replied.

The offline place cache hung off the map-tile switch. That setting is about
tiles — its own text says "off keeps the cache to trip data + documents only" —
and it gets turned off for space, tens of megabytes of it. A trip's places are
about one. Somebody who turned tiles off therefore got an empty offline search
with no explanation, and "prepare for offline" reported success having stored no
place at all. The two run independently now; the tile progress phase stays gated
on the tile setting, so a run without tiles still reports no tile phase.

Tests for the merged search path, which had none: the wiring around
mergeSearchResults was uncovered, so which list goes in first and what `source`
says were both free to change. Neither is cosmetic — index-first is the whole
point of the feature, and the search log writes `source` into the corpus a
candidate index gets scored against later. Two of the eight cases pin the fix
from the previous commit: an index failure keeps the OpenStreetMap answer, and
neither source is asked twice.

place-shadow lands on its own coverage entry rather than the 80 catch-all, where
a new domain could shed twenty points unnoticed. Pinned under its measured
figure on purpose; the exact ratchet gets regenerated with the repo's own script
after a full run.
2026-09-07 14:05:33 +02:00
Maurice 48f0f29273 feat(admin): let an operator switch the place index off
trek_places_enabled was read by six call paths — search, autocomplete, the POI
pills, booking-import geocoding, the offline area and details resolution — and
written by nothing. No admin route, no MCP tool, no switch anywhere in the UI,
and the key is not in the settings allow-lists either, so the only way to set it
was an INSERT against app_settings by hand on the server.

That matters more than the four switches beside it, which trade away quota. This
one decides whether every search and every keystroke in the place box leaves the
instance at all. The flag reads fail-open, so an install that upgrades without
visiting the admin panel starts sending them, which is the right default and
exactly why the operator needs somewhere to say no.

Adds the pair symmetrically to place-shadow from the same branch: getTrekPlaces
/updateTrekPlaces on AddonsService, GET/PUT /api/admin/trek-places with an audit
row, and a real switch on the card. The read stays fail-open so it keeps
agreeing with MapsService.trekPlacesEnabled(), and a test pins that agreement
across every stored value — if those two ever diverge the panel shows "off"
while the queries keep leaving, which is worse than either default.

The card said this itself: `available` was a display-only prop whose comment
read "False until the client is wired to the service; the card then says so
instead of offering a switch that would do nothing." It is wired now, so the
prop is a switch. Its two badge strings had been shipped to all 23 locales and
rendered nowhere; they make way for the switch label and a line naming what
answers while the index is off.
2026-09-07 14:05:33 +02:00
Maurice 5aa36aaf3d fix(maps): stop the index costing a second search, a duplicate and a lost cache
Six things the merged search and the offline cache got wrong.

An index failure took the OpenStreetMap answer down with it. Only the OSM half
of the Promise.all had a catch, so a rejection on the index side discarded ten
results that had already arrived and sent the same query to Nominatim a second
time, behind its 1.1 s process-wide throttle. The index refusing a query is
ordinary traffic rather than an outage — a common single word without
coordinates is turned down upstream as too expensive — so this fired in normal
use. Each side now catches its own failure, and the keyless fallback reuses the
answer OpenStreetMap already gave instead of asking again for a list that
cannot have changed.

Dedup never fired for a non-Latin name. namesOverlap splits on [^a-z0-9], so
長谷寺, Кремль and مسجد الشيخ زايد all tokenise to nothing and the function
returned false — the same place came back once from each source, one row under
the other, and picking either gave it a different id. That is the corpus this
whole feature was measured on. Two names with no Latin words are now compared
strictly, folded through NFKC so the width variants the sources disagree on
still match; sharing a character is deliberately not enough, because deduping
東京タワー against 東京タワー駅前郵便局 would swallow a real result.

expand=1 answered nothing for an index place while expand=0 answered in full.
The expanded lookup degrades OSM ids to the plain one and had no branch for the
new family, so the richer request was the poorer one, over REST and MCP alike.

The bias circle could exceed Google's 50 km ceiling. A trip spread over more
than that produced a radius Google rejects outright, turning a search into an
error toast. Clamped on both sides — the client because it draws the box, the
server because the radius arrives over the wire.

The offline cache was rebuilt on every sync and never deleted. syncTrip wrote
its meta row with put(), which replaced the whole record and dropped the
fingerprint saying the cached area was current, so every login, reconnect and
manual sync re-downloaded megabytes that had not changed. And clearTripData
never touched areaPlaces, so a trip switched off for offline use kept answering
offline searches and its rows stayed for the life of the install.
clearCachedPlaces, which claimed to do that job, was called from nowhere; its
only test asserted with .every() on an empty table and passed on nothing.

Picking an offline suggestion ended in an error toast. The details lookup had
no cache path, so it failed and the callers fell back to searching for
"name, address" — which the cache matches on the folded name alone, so it found
nothing for a place that was sitting right there. A miss stays a miss.
2026-09-07 14:05:33 +02:00
Maurice 273bac8917 fix(maps): keep index ids away from Google, and let the area download be big
Four things the index changed and nothing followed:

`gers:` was missing from NON_GOOGLE_PLACE_ID, so isGooglePlaceId said true
for every id the index hands out. Five call sites gate on that function to
keep unresolvable ids away from Google, which answers them with a billable
400 INVALID_ARGUMENT: photo refs, the editorial summary, the place-photo
route, the expanded details lookup and the enrichment photo pass. Since the
index answers first for search and autocomplete, a GERS id is what an
ordinary new place now carries, so this was the normal case rather than an
edge one — three billed lookups per added place, none of which could ever
return anything.

The area download shared the search path's megabyte cap and its 3.5 s
timeout. Both are right for a keystroke and wrong for the offline prefetch,
which asks for 3000 rows at once: a city-sized answer sits on that megabyte,
readCapped threw, and the caller could only report "index unavailable" — the
trip then went offline with an empty place cache and nothing said so. The cap
now follows the row count the caller asked for, with a floor, and the bulk
call gets a budget that fits a bulk download. It is still capped, so a broken
upstream cannot hand us an endless body.

searchPlaces returned before the Google branch as soon as the index or
OpenStreetMap had anything, and both set google_place_id to null. That is
right for a search and wrong for the one caller that wants an identity rather
than an answer: list-import enrichment (#886) exists to attach a
google_place_id to a bare imported pin, and pickEnrichmentMatch discards every
candidate without one. So the import silently stayed unenriched on an instance
that pays for a key. That caller now asks for the Google identity explicitly.
Nothing changes for an instance without a key — enrichment could never resolve
anything there, before or after.

Tests cover the three functions this branch had left untested, which is what
put the maps ratchet under its functions threshold: the autocomplete index
path (a new file, MAPS-AUTO-001..007), the gers details branch when Overpass
is down (MAPS-GERS-008), and the client's abort timeout — including that the
signal exists at all, which nothing asserted before.
2026-09-07 14:05:33 +02:00
Maurice 2b7af6267b test(server): cover what this branch added to the pinned domains
The coverage ratchet measures per domain, and this branch put new code into
four domains pinned well above the floor without tests reaching it.

addons (99/99/99): getPlaceShadow and updatePlaceShadow had none at all, and
the parity the service comment claims with PlaceShadowService.enabled() is now
asserted rather than asserted in prose.

maps (93/86/97/96): geocodeQuery was invisible - its only consumer stubs it
away - so the new suite drives the gate, an accepted hit, a hit with an
unusable coordinate, an index failure falling through, and both ends of the
Nominatim tail. It sits on the booking-import path, where it decides whether
thirty sequential lookups leave the instance.

admin (86/70/89/88) and place-enrichment (96/95/94/97): the place-shadow
handlers and their audit entry, the getAppConfig read, and nearbyWouldMislead,
whose lines ran incidentally while nothing asserted the result - the guard
could have been inverted and the suite would have stayed green.
2026-09-07 14:05:33 +02:00
Maurice d613c91e16 fix(places): keep the chain out of the field that means "this place"
toPlaceRecord wrote the brand's Wikidata item into `wikidata`, and
getPlaceDetails spreads the record over the OSM details, so for a chain branch
picked from the index that value replaced the building's own tag - and nulled a
real one when the place had no brand. resolveIdentity reads that field as the
place's own identity, so the photo ladder returned the chain's Commons files as
pictures of this branch and the description came back as the company's article
without the aboutBrand flag.

That is precisely the failure WIKI_IDENTITY_TAGS and readBrandIdentity were
written to prevent, in their own words: "a chain's logo passed off as a photo of
the restaurant would be a plain lie". The item now travels under brand:wikidata,
the key OSM uses and readBrandIdentity already reads, and resolveIdentity picks
it up from the payload for a place OSM does not know - so a branch still gets
the chain's description, flagged as such, and never its logo.

The client test also covers trekPlacesArea, the streaming half of readCapped
including the size cap, the configured base URL and the nearby defaults, none
of which any test reached.
2026-09-07 14:05:33 +02:00
Maurice 0991fa538b fix(maps): stop pois() dropping opening hours and reporting a narrowing as none
The index-first branch hardcoded opening_hours: null while the payload it maps
carries them, and search_pois advertises "opening hours wherever OSM carries
them". It also capped the radius at 20km and then answered clamped: false, so a
wide viewport came back narrowed and said it was not - the same field the
Overpass path fills honestly.

The three red MCP cases were the same code telling the truth from the other
side: they stub Overpass one level below the facade, so pois() ran for real,
and because trekPlacesEnabled() fails open on a database with no row, a unit
test was sending live requests to places.liketrek.com from CI. That is what
answered with 50 places instead of the stubbed one. The index client is now
mocked in that suite, with the gate left open so the cases still run on the
shipping configuration, and the index-first path gets cases of its own here and
in a new maps.pois suite: field mapping, the empty and the failing index both
dropping through to Overpass, and an outage under both providers still reaching
the caller as an error rather than an empty list.

maps.service.test.ts drove pois() with no stub at all and leaked the same way.
2026-09-07 14:05:33 +02:00
Maurice 65eebec53f fix(mobile): hint the suggestion fallback the same way the search is hinted
When details fails to resolve a picked suggestion the sheet falls back to a
text search (#1192). That call was the one search left in the sheet without a
location hint, so the fallback could land on an identically named place in
another country - exactly what the hint exists to prevent, on the path taken
when the first hop already failed.
2026-09-07 14:05:33 +02:00
Maurice 15c1a5ae06 test(mobile): seed the store the location hint is actually read from
useLocationBias reads useTripStore; the sheet's harness only ever built a
planner prop. So the hook saw an empty store, sent no hint, and the one case
that asserts a box went red while its three siblings passed vacuously - they
expect undefined, which an empty store produces no matter what they seed.

capturedBias now seeds places, assignments and selectedDayId the way the
running app has them, which puts all four cases back to work. The 500km case
is retargeted from Tokyo/Sapporo to Tokyo/Nagoya: 264km across, biased under
the old diagonal rule and dropped under the 60km radius one, so it guards the
rule that actually shipped. A new 029b pins the day-first precedence - a trip
too wide to bias still hints at the day being planned.
2026-09-07 14:05:33 +02:00
Maurice 4c52d16b7e test(admin): follow the API keys card to the theme tokens
The three AdminPage cases located the card with closest('.bg-white'), which
returned null once the card became bg-surface-card, and within(null) reports
that a line later as "container is null". Anchor on .rounded-xl, the same way
the green AdminSettingsTab suite already does, and ask for the single Test
button rather than the first of several: weather left the card when it stopped
needing a key.

FE-ADMSET-031 asserted the Open-Meteo panel inside that card. The panel was
deliberately removed from the desktop tab - it needs no key and had nothing to
configure - so the case now pins what took its place, the TREK index heading
the card with the recommendation the Google key used to carry. The panel itself
still lives in the mobile section, where FE-MOB-ASET-019 asserts it.
2026-09-07 14:05:33 +02:00
Maurice c0eb1c6e38 fix(maps): send the location bias the search route already accepted
MapsSearchDto validates a locationBias and MapsService passes it through, but
no caller ever sent one. That gap is the whole difference between the numbers
in this PR and what the software does. Measured against a live instance over
the 127 places of a real trip, searching each by name:

  without a hint   50.0% -> 54.8% top-5
  with a hint      50.0% -> 73.0% top-5   (this PR reports 72.7%)

mess/eval_merge.mjs sends lat/lng on every index call and a viewbox to
Nominatim; the client sent neither. Without coordinates the index refuses
common queries as too expensive and contributes to 41% of searches instead of
100%, so the merge degenerates to Nominatim alone.

The hint has to be NARROW, which is the part that is easy to get wrong. Same
corpus, same instance, varying only how the box is derived:

  no hint                            54.8% top-5, 38.1% at rank 1
  box around the whole trip          57.1% top-5, 17.5% at rank 1
  box around the nearest few places  70.6% top-5, 34.9% at rank 1

A box spanning a round trip has its centre in open country between the cities.
It points at nothing and costs more than half the rank-1 hits. useLocationBias
therefore prefers the day being planned, falls back to the trip only while it
still fits inside one region, and drops the hint past a 60km radius rather
than sending a misleading one.

This also replaces the two hand-rolled copies of the bounding box in the
planner dialog and the phone sheet, which shared a defect: Number(null) is 0,
so a place without coordinates entered the box as the Gulf of Guinea and
stretched it across the planet.
2026-09-07 14:05:33 +02:00
Maurice fb363f22c8 fix(maps): alternate between the index and OpenStreetMap instead of ranking them
Measured on 128 places out of a real trip to Japan, all saved through
the old search. Index alone: 51.6 percent inside the top five. Index and
OpenStreetMap alternating: 72.7 percent.

Two cleverer orderings were tried first and both lost. A score threshold
cannot separate the cases at all: at 0.75 it catches 19 of 62 misses and
throws away 6 of 66 hits. Promoting results whose name matches what was
typed is worse than it sounds, because "Hase Station" shares a word with
"Hase-dera" while OpenStreetMap answers in the local language and
returns the right temple under a name that shares nothing: that rule put
seven weak index matches ahead of the correct answer, which landed at
position eight. Alternating puts it at position two.

So the merge does not rank. Each source keeps its own ordering and they
take turns, index first. An exact business match still opens the list;
a landmark Overture does not carry is second rather than eleventh.
2026-09-07 14:05:33 +02:00
Maurice 3caafe0548 fix(maps): show OpenStreetMap alongside the index, not instead of it
Measured against 128 places out of a real trip to Japan, all saved
through the old search: the index answered 51.6 percent inside its top
five. For most of the rest it returned the shops AROUND the landmark
rather than nothing, which is worse than nothing, because ten plausible
wrong answers look like an answer.

Overture Places is a dataset of businesses and is very good at those. It
largely does not carry temples, bridges, riverside walks, viewpoints or
observation decks, which is a fair share of what somebody planning a
trip searches for.

Two ways to detect "the index does not have this one" were measured and
both failed. Its own score does not separate the cases: a 0.75 threshold
catches 19 of 62 misses and throws away 6 of 66 hits. Name overlap does
not either: 57 false positives, because "Hase Station" shares a word
with "Hase-dera".

So this does not classify. Explicit search asks both concurrently and
merges, name matches first and the index ahead of OpenStreetMap within
each group. Autocomplete is untouched, because Nominatim's policy names
it as unacceptable use. Nominatim also gets the location bias it never
had, without which "Hase-dera" returns the temple in Nara rather than
the one in Kamakura the user is standing beside.

Two booking-import cases still stubbed searchNominatim after geocoding
moved to geocodeQuery, and stubbed it with the old array shape, which
made "has coordinates" pass on an undefined lat.
2026-09-07 14:05:33 +02:00
Maurice bac85cb98a feat(places): opening hours from the index, with OSM still first
The index can now carry opening hours, from two sources: an
OpenStreetMap layer the service keeps as a separate database file, and
the schema.org data a place publishes on its own site, read in the same
page fetch the description comes from.

OSM stays the first choice where it has them. Measured across seven
countries it covers 27.5 percent of gastronomy, and an OSM entry
describes this exact object and gets corrected by people who walked past
it, where a chain's website often carries one set of hours for every
branch. The rest is what the operator publishes, which measured 8.3
percent on the same kind of place.

Both go through the same expansion into weekday lines, so a card reading
`opening_hours` gets one shape and not two.

Five maps.service cases went red when search started answering from the
index ahead of Google; they are about the shape of the Google request,
so they now switch the index off first.
2026-09-07 14:05:33 +02:00
Maurice 87a765b763 feat(offline): cache the trip's area so place search works with no network
An offline instance had the trip's own places and nothing else, so "find
a pharmacy near the hotel" had nothing to answer from: every lookup went
to a server.

The index can hand over a bounding box in one request, so it does, once,
next to the map tiles. A trip to Rostock is roughly a megabyte, refreshed
only when the trip's area actually moves. Search and autocomplete fall
back to it at the API layer, which is why all ten call sites got the
offline path without any of them learning about it, and the answer says
`source: 'offline-cache'` rather than pretending to be live.

Country packages stay what they were: a SQLite file for anyone who would
rather run their own copy of the service. A browser cannot query one
without shipping a WASM SQLite, and Germany alone would not fit in
IndexedDB, so that door stays where it belongs. /v1/packages lists them,
because "you can leave" only means something if the door is visible.
2026-09-07 14:05:33 +02:00
Maurice 715993feaf feat(places): descriptions from the place's own website
Overture has no descriptions, and the encyclopaedias only describe the
places that have an article, which is a fraction of a percent. What
Overture does have is the address of the page the description is on, for
43 percent of places, and those pages publish a summary in JSON-LD or
og:description precisely so machines can read it.

The API fetches each page once and keeps the summary; every instance
reads it from there. TREK never opens a business's website itself, which
would mean one request per instance per place against small servers.

In the ladder it sits below the encyclopaedias and above Google: an
article about this exact place beats the operator's own copy, but for a
restaurant or a shop the operator's summary is the only one that exists.
Credited by host and linked back, with no licence claimed.
2026-09-07 14:05:33 +02:00
Maurice b3d69aa760 feat(places): record which search result was picked, on this instance only
The corpus behind "is our own index good enough to replace Nominatim".
When somebody searches and then picks a result, the client posts the query,
the rank and the pick. Nothing else: no user, no trip, no session, and
coordinates only to three decimals.

Deliberately decoupled from the search endpoint. Correlating a response
with a later pick would mean handing out a ticket on POST /api/maps/search
and threading it through all fifteen call sites; the client already holds
everything the row needs at the moment of the pick.

Evaluation happens offline afterwards: export the corpus, run it against a
candidate index, count how often the pick came back in the top five. That
is why collection can start before such an index exists, which is the whole
point, because the corpus needs weeks of real use and the index needs a
server, and those two waits should not be serial.

Off unless an admin switches it on, and it reads === 'true' rather than
!== 'false'. The three older places switches fail open because a migration
backfilled a row for installs already using them; nothing uses this one, so
an instance that upgrades and never visits the admin panel logs nothing.

Reading is admin only, retention is 180 days, and the switch state is on the
unauthenticated app-config endpoint on purpose: whether an instance records
what its users search for is something a visitor may know before logging in.
2026-09-07 14:05:33 +02:00
Maurice 0a2fa32fc1 feat(admin): rebuild the API keys card around the TREK index
The card was a flat list: two key fields, four Google switches and a large
weather panel, all at the same level. That gave a switch you set once the
same weight as the key it depends on, and gave the loudest treatment on the
page to the one thing with nothing to configure.

Now three comparable provider blocks. The TREK index carries the accent and
the recommendation, because a key should be the exception rather than the
expected setup. Google carries no border badge, so it does not stand out,
and says plainly what it costs beyond money. Weather has left the card: it
needs no key and had nothing to set.

The "recommended" badge moved from Google to the index, which is the whole
point of the change.

Also here, and both are their own thing:

- The settings tab is two columns from xl up. Nearly every card is "label
  left, switch right", so the middle stayed empty while the page scrolled
  for ages. Explicit columns rather than a grid over the flat list, because
  a grid pairs cards row by row and leaves a hole under the shorter one,
  and these differ by hundreds of pixels.
- Pictures: the nearby fallback drops from 300m to 60m and is skipped
  entirely for shops and gastronomy. Measured across 600 places in six
  cities, 2 percent of ordinary businesses have a picture on Wikimedia
  against 70 percent of churches, so for a cafe the curated rungs never
  fire and the fallback always does. That is how the town hall ended up
  over the doner shop. It fails open on an unknown category, so landmarks
  keep the fallback that works for them.

vite.config reads the dev port and the API target from the environment,
defaulting to what they were. Several worktrees could not run side by side.
2026-09-07 14:05:33 +02:00
Maurice 7f2a63e7f7 feat(maps): search places through the TREK Places API
Adds a provider for the project's own place index and puts it in front of
the existing ones for search, autocomplete, details and the explore pills.
It needs no key, has no quota, and what it returns may be stored, which is
true of neither of the two providers it sits in front of.

Nothing about the Google path changes. Every call site falls through to
exactly what it did before when the index has no answer or the service is
unreachable, so a working search keeps working.

Two details worth knowing while reading:

- Details for a place from the index also resolve its OpenStreetMap record
  by name and coordinate, because the index has no opening hours yet and
  never has cuisine or a menu link. Without that the hours a user saw while
  adding a place vanished from its card afterwards.
- The explore pills keep reporting source 'openstreetmap'. It is a typed
  contract the client switches on, and the places are the same places.

TREK_PLACES_URL points at a different instance; unset means the public one.
2026-09-07 14:05:33 +02:00
Konstantinos Thermos 2a0ceb2687 feat(transit): let an admin route transit search through Google
Transitous has no GTFS coverage for much of Asia, so a search between two
Osaka subway stops finds nothing — the stops are not in the feed at all.
Add Google as an optional backend behind an admin setting, reusing the
install's existing Places key, and fall back to Transitous whenever no
key resolves.

Uses the Routes API rather than the legacy Directions endpoint the
request named: every other Google call here already speaks the
v1/X-Goog-Api-Key family, and Google closed the legacy endpoint to Cloud
projects created after March 2025.

Google returns walking as turn-by-turn navigation steps where MOTIS
returns one leg per walking segment, so consecutive walk steps are
coalesced into a single leg — otherwise a finely-sliced walk pushed a
real journey past the 20-leg cap and the whole itinerary was dropped.

Both responses now name the backend that answered. The fallback is
silent by design, which left an empty result ambiguous between "no data
here" and "the provider you picked never ran", and the admin panel warns
when Google is selected but no key resolves — or when only the admin's
own key is set, which serves them and nobody else.

Cost shaped the implementation, since Google bills where Transitous does
not: both field masks are cut to the cheapest SKU tier that still
answers the question, cache TTLs are longer than the Transitous path's,
and route alternatives ride along inside the already-billed request.

Addresses #1699
2026-09-07 09:01:56 +02:00
Maurice 8e2840b29d docs(helm): document the probes block, and survive probes being nulled
A probe map is exactly the value someone reaches for when their cluster is
unhappy, so the two ways of reaching for it should not break the render.
`probes: null` hit a nil pointer on `.Values.probes.liveness`; guarding with
`with (default dict ...)` also makes dropping a single probe work, which is what
you want on a pod whose liveness check is the thing killing it.

The rest is documentation the chart already gives every other value: a wiki
section next to Resource Limits, a Notes bullet, and a values comment naming the
one trap here, that a handler switch has to clear the shipped httpGet in the same
override or the API refuses the pod.

Verified with helm template: defaults render byte for byte what the chart
produced before, probes=null renders no probes, probes.liveness=null leaves one,
and helm lint passes.
2026-09-06 17:26:51 +02:00
Owen Holloway 0454a383b3 feat: make health probes configurable in Helm chart 2026-09-06 17:26:51 +02:00
dependabot[bot] 18684f20f3 build(deps): bump @simplewebauthn/server from 13.3.1 to 13.3.2
Bumps [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) from 13.3.1 to 13.3.2.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.2/packages/server)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-06 17:10:12 +02:00
github-actions[bot] 8913782694 chore: bump version to 4.2.0 [skip ci] 2026-09-06 17:10:12 +02:00
dependabot[bot] 52315234fd build(deps-dev): bump browserslist from 4.28.2 to 4.28.8
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.2 to 4.28.8.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.2...4.28.8)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-06 17:10:07 +02:00
Maurice 2572132117 fix(notices): let the 4.0.0 release modal cover the whole 4.x line
The version window already said 4.0.0 to 5.0.0, and the test above it is even
called "covers the whole 4.x line", but that was only half the story: without
`recurring: 'per-version'` a notice falls back to a one-time dismissal, so
everyone who closed the modal on 4.0.0 never saw it again. It did not come back
on 4.1, 4.2 or 4.2.1, which is the opposite of what the window says.

The mechanism was already there and in use one entry below, on thank-you-support.
The dismissal path records the app version it was closed at, so the notice
re-surfaces on the next upgrade and stays down within the same version. Rows
dismissed before that column existed read as 0.0.0 and simply come back once.
2026-09-06 16:50:47 +02:00
Maurice 755e9f1ca4 fix(trips): stop the two date fields overlapping in the trip dialog
A grid item defaults to min-width:auto, so the two date cells never went below
the width of their contents: a long locale date plus the keyboard button beside
it pushed each cell past its track, and on a phone the end date came to sit on
top of the start date with its own button clipped off the edge. Measured on a
412px viewport, the row overflowed its container by 30px.

Both cells get min-w-0 so the track wins, and the picker's own trigger gets it
too, which is what finally lets the ellipsis it already carries do its job
rather than the button simply growing. "12. Sept. 2026" now reads "12. Sept…"
in a narrow column instead of escaping it.

Keeping the locale format: the report suggests ISO 8601, but the date is
formatted from the user's language everywhere in the app, and the overlap is
the layout, not the wording.
2026-09-06 15:39:30 +02:00
Maurice b132adf3e3 fix(test): repair the escaped newlines and wire the new controller arg
The stderr and --version fixtures had literal line breaks in them, and the
admin controller helper still built the controller with eight arguments.
2026-09-06 15:03:13 +02:00
Maurice 94914295d1 fix(llm): let a model refuse temperature, and say which extractor is installed
Two reports, both about an import that fails without saying why.

#2262 — temperature: 0 sat in the shared request body, so it went out on every
attempt including both retries. Reasoning models (the gpt-5 family) reject any
explicit temperature, and the json_object retry fires on any 400, so the import
spent two doomed requests and could not succeed at all. The parameter is now
dropped once a server has explicitly said it will not take it.

Not as a third link in the chain, though. A gpt-5 rejects max_tokens AND
temperature, the API names one parameter per response, and it may name either
first — a chain of one-shot ifs converges on only one of the two orders. The
retries are a loop over what the server actually said instead: each remedy
applies at most once and they compose. Detection is a phrase, not the bare word,
because dropping temperature costs the deterministic sampling that small local
models depend on, and an unfamiliar wording still falls through to the
json_object retry exactly as before. NuExtract and Ollama are untouched: the
first attempt always carries temperature, and it is the only one they ever see.

#2261 — the bundled extractor is Debian trixie's 24.12.3, roughly eighteen
months behind upstream, and the reporter is right that this is invisible: a
stale extractor, a mistyped KITINERARY_EXTRACTOR_PATH and a genuinely
unsupported provider all look the same from the UI, which is nothing imported
and no reason given. The version now appears in the startup log and in a new
admin-only GET /api/admin/system-info, and LOG_LEVEL=debug passes the
extractor's raw stderr through — the JS ERROR and Invalid-result-type lines the
filter drops are the only evidence that a vendor script is missing or failing.
Capped at 200 lines, and opt-in because those lines can carry document
fragments.

Not bumping the package: the filter the reporter needs first ships in KDE Gear
26.08 and no Debian suite carries it, and sid's 26.04.3 wants KF6 6.22 and Qt6
6.10.2 against trixie's 6.6 and 6.8.2 — a partial upgrade of Qt6, KF6 and libc6
from unstable into the production image, on two architectures, that still would
not fix the reported carrier. It comes for free with the base-image move after
Debian 14.
2026-09-06 15:03:13 +02:00
Maurice 85f5247a00 fix(test): the card is labelled Total trip spend 2026-09-06 15:03:08 +02:00
Maurice 8a48131618 test: pin the empty-metadata payload and the payer-less share
Two suites encoded the behaviour this branch changes: MReservationSheet
asserted the literal metadata: null the forms no longer send, and the new
Your-share case reached for a label that is a card footer rather than a
heading.
2026-09-06 15:03:08 +02:00
Maurice 0a35ce83a5 fix: the follow-ups the v4.2.1 audit turned up
Five findings from auditing the release branch, none of them a blocker for it,
all of them things it would have shipped with.

The mirrored booking price only survived an edit when the form happened to fill
metadata of its own. Both reservation forms and the phone sheet sent
`metadata: null` whenever the form had no metadata key to send, and null clears
the column outright — so restaurant, event, tour, parking, other, a hotel with
no check-in times and a transport with no carrier details all lost the price
again on the next save. They send the object now: it still clears what the form
dropped, and the server carries the price across (#2233).

Your share counted expenses nobody has paid for, while the balances right below
it stopped counting them. The tile contradicted the panel it sits in (#2225).

App.tsx kept its own list of routes that must not mount authed-only widgets, and
it had already drifted from the one the response interceptor uses: this one
named /public/journey/, that one covers all of /public/. It reads
isAuthPublicPath now, so a route cannot be public to one and not the other.

LLM_TIMEOUT_MS and TREK_PLUGINS_IGNORE_TREK_RANGE shipped in .env.example but
not in the compose file most installs actually read.

And a typo in the Russian offline copy: Завершёна → Завершена.
2026-09-06 15:03:08 +02:00
Maurice eaace6ada4 fix(test): assert the untouched GET properly
toMatchObject({ method: undefined }) does not say what it looks like it says.
2026-09-06 15:02:58 +02:00
Maurice e6b4df5213 test(ssrf): pin what a redirect hop may carry across an origin
The credential strip and the 303/302 body downgrade are new behaviour in a
security helper, and src/utils sits on a 96% line ratchet that the untested
branches took under.
2026-09-06 15:02:58 +02:00
Maurice 2d315df8bf fix(security): keep the operator's ntfy token in and re-check every redirect hop
Two reported advisories, and a third path neither of them covers.

GHSA-7pqc-fj3c-9346 — the ntfy token fell back to the operator's whenever a
request did not carry one, so an authenticated user who named their own server
was handed the operator's decrypted token as `Authorization: Bearer`. The report
covers `test-ntfy`, but the live send path in channels/builtins.ts does the same
thing off a per-user `ntfy_server` setting: no test route needed, just wait for
the next notification. Gating the route on `role === 'admin'` would have closed
one of the two and broken the documented shared-server setup, where the operator
supplies server plus token and users only set a topic. The invariant is the
target, not the role: the operator's token travels to the operator's server and
nowhere else, and that rule fits both paths unchanged. Scheme is part of the
comparison — an http twin of an https server would put it on the wire in clear.

GHSA-8mw6-xphx-886m — both transports checked only the first URL and then let
the platform follow redirects on a dispatcher pinned to that first IP. Pinning
does not help: Node skips the pinned lookup for an IP-literal host, so a 307 to
127.0.0.1 or 169.254.169.254 connected straight through. They go through
safeFetchFollow now, which re-checks and re-pins per hop. safeFetch itself had
the same shape and carried it to sixteen more callers, several with a URL out of
a per-user setting, and one (pipeAsset) streams the response back — which would
have made a redirect into an internal service readable rather than blind. It
now routes through the same follower.

The third: following by hand opts out of the platform's own rules, and both
manual followers replayed the caller's init verbatim. undici drops
`Authorization` across origins itself, so converting ntfy to safeFetchFollow
would have re-opened the first advisory through a redirect. Credential headers
are now dropped on an origin change (same-host http to https stays, self-hosted
setups depend on it), and a 303 — or a 301/302 on a non-idempotent method —
drops the body, so a plugin's client_secret is not re-POSTed at a host the
first one merely pointed at. plugin-oauth pins maxRedirects to 0 like its OIDC
twin, and the Google Places calls get the timeout none of their nine call sites
passed.
2026-09-06 15:02:58 +02:00
Maurice 04b31ec2e5 fix(mobile): give the trip map a way back to every day
The phone map filters down to one day and had no way back out. The chip
rail can only swap one day for another, the second tap on the active chip
is the day sheet, and the seed guard deliberately does not re-seed — so a
day picked on the map stayed picked until you left the trip and came back
(#2257). Desktop has had the deselect since the day header got it.

Its own round control beside the rail, map view only: the plan timeline is
single-day, so there is nothing to widen there, and the day sheet keeps the
one route into it that also exists on the map. It toggles — the first press
parks the day it came from and frames the whole trip, the second puts that
day back with its route, or falls back to the focus day if a remote delete
retired it in between. Leaving the map restores it too, since an empty
timeline would be the worse answer.
2026-09-06 13:35:30 +02:00
Maurice 8eda1475c3 test(journey): pin the viewless-map window the photo layer fell into
The Leaflet mock had no getCenter, so the new guard bailed out of every
draw and FE-COMP-JOURNEYMAP-046/047 went red. Give the mock a loaded map
by default and add FE-COMP-JOURNEYMAP-050, which takes the view away for
exactly the window #2254 hit: render must survive it drawing nothing, and
the moveend listener must still put the thumbnail up once fitBounds lands.
2026-09-06 13:25:43 +02:00
xvilo ea0589b21e fix(journey): guard photo layer draw against map with no view yet
Public journey pages ≥1024px render the sticky sidebar map immediately with photos, before the deferred fitBounds/setView lands, causing latLngToContainerPoint to throw "Set map center and zoom first."
2026-09-06 13:25:43 +02:00
xvilo 523443f1d7 fix(app): stop mounting authed-only widgets on public visitor routes
SystemNoticeHost, BackgroundTasksWidget, and save-to-collection were gated only on login/register/password routes, so anonymous visitors to /shared/:token and /public/journey/:token still mounted them and hit system-notices/active with no session, logging a 401 on every visit.
2026-09-06 12:56:04 +02:00
Maurice 8c538db74d test(e2e): locate the panel tabs by aria-label, the tab bar has a Plan button too 2026-09-05 13:22:56 +02:00
Maurice 11a0780a5b fix(planner): give narrow screens one panel at a time, and unclip the public language picker
Three reports from the same week, all "the layout is in my way".

#2247 — the Plan tab shows both side panels from 768px up, unconditionally.
A Pixel 10 Pro Fold unfolded is ~860 CSS px, so 340 + 300 of panel left the
map a 200px strip; those same widths became fit padding, shrinking the
framing window to 140px and opening the camera two zoom levels out over half
of East Asia. The floating POI/compass cluster is centred on the viewport at
z-25, which at that width put it on top of both collapse tabs (z-1 inside
z-20 containers) and on the Add Place/Activity button — and its pills set
pointer-events: auto, so it ate the taps meant for them. The resize handles
are mousedown-only, so a touch user could not narrow anything either.

Between 768 and 1023px only one panel is open now, the day plan first, the
places list one tap away — the Google Maps shape the reporter asked for. The
collapse flags stay the wide-layout state, so a fold back restores what was
there, and a marker tap reopening "both" no longer re-crowds the screen. The
control cluster centres on the corridor the panels leave (the formula
PlaceInspector and DayDetailPanel already use), the tabs finally have
accessible names, the dead resize handles go, and a panel dragged to 520px on
a desktop is clamped so the map keeps 360px without touching the stored
width. computeMapViewport refuses to frame for less than a third of the box,
so absurd padding can never pick the zoom again.

#2248 — the public journey hero clips its own overflow to keep its
decorative circles from widening the document, which also amputated the
language menu at the hero's bottom edge: 8 of 23 locales, the rest
unreachable. Both share pages inlined the same menu, so it is one component
now, portalled to the body and anchored to its trigger the way CustomSelect
is, with a height cap and its own scroll. It closes on Escape and on an
outside click, which it never did, and `zh-TW` stops labelling itself 简体中文.

#2249 — the note row's pencil/trash pill is absolutely positioned over the
row's own reorder chevrons, and a coarse-pointer `opacity: 1 !important`
pinned it permanently visible on tablets. On a mouse it was no better: the
chevrons are revealed on hover, which is exactly when the opaque pill covers
them, so note reordering has never been clickable on any pointer. The pill is
gone; the row opens the note the way the phone shell already does, and delete
lives in that dialog behind the same confirmation. A link in the note body
keeps its own tap, on both shells.

Closes #2247
Closes #2248
Closes #2249
2026-09-05 13:22:56 +02:00
Maurice f75e9e9af8 fix(collections): read the created list off the response the server actually sends
POST /api/addons/collections answers with the collection itself — the
controller returns the service's `Collection` straight through, and the e2e
suite pins that shape. The client declared a `{ collection }` envelope and
read `.collection` off it, so `createCollection()` resolved `undefined` and
handed back `null` every single time.

The caller needs that id. In the list editor and its phone sheet the id is
what the cover upload and `onCreated` hang off, so creating a list with a
cover picked in the same dialog silently dropped the image, the new list was
never opened or selected afterwards, and the retry guard that exists to stop
a failed cover step from minting a duplicate was dead. The list itself still
appeared, because `loadAll()` runs first — which is why this went unnoticed.

Nothing caught it because both test layers stubbed the shape the client
believed in rather than the one the server sends: the api suite's MSW
handlers wrapped the collection in an envelope, and the store suite's mock
resolved one too. Both now answer the way the controller does, `update()`
included — its declared type was wrong for the same reason, though no caller
reads its result.

The fix is on the client. That wire shape has shipped for as long as
Collections has existed and the server e2e test pins it, so changing that
side would be the breaking one.
2026-09-05 13:19:01 +02:00
Maurice 3e532aab99 fix(planner): never link an offline temp place id to a hotel booking
The envelope fix routes the created place through the store, which offline
mints a NEGATIVE temp id: reservations have no offline write path, the
mutation queue rewrites temp ids in a URL but never inside another entity's
body, and day_accommodations.place_id carries a foreign key. So a
forced-offline save (the Settings toggle sets the flag while the network is
still live) reached the server with place_id: -1758..., failed the FK inside
the insert transaction and rolled the whole booking back into a 500 — where
dev merely saved it unlinked. Worse, the temp place is in `places` by then,
so every retry matched the same negative id and the booking stayed unsavable
for the rest of the session.

Offline the function now links nothing at all and skips the geocode too: the
booking cannot be written either way, so minting a place would only leave an
orphan behind on the next flush. The name-match arm rejects temp ids for the
same reason. `as never` is gone from the addPlace argument, and
placesApi.create finally declares its `{ place }` return type — an untyped
`r.data` is what let the envelope be misread in the first place.
2026-09-05 12:58:34 +02:00
Xheldon 6953ede0e3 fix(planner): link the place created for a hotel instead of minting a new one on every save
resolveImportedPlace created the hotel's place through placesApi.create and
read .id off the response, but that endpoint answers { place }, so the id was
undefined: the accommodation was saved without a place_id and the new place
was orphaned. Because the planner's places state never learned about it, the
name match failed on the next edit too, and every save of the same hotel
produced another copy (five in a row on one trip).

Create the place through the store's addPlace instead. It unwraps the
envelope, so the accommodation is linked, and it pushes the place into
`places`, so a later save of the same hotel reuses it. The mobile sheet goes
through the same handler and is fixed with it.

The hook tests mocked placesApi.create with a bare { id } and therefore
never saw the envelope; they now assert on addPlace, and a new case saves the
same hotel twice and expects one place.

Closes #2243
2026-09-05 12:58:34 +02:00
Maurice f81aaa9f4b test(login): stub the world animation so the takeoff assertions stop flaking
FE-PAGE-LOGIN-014 failed intermittently in CI while passing in isolation:
twice in a row on unrelated PRs, each time costing a full run, and once on
one of two runs of the identical commit.

The takeoff overlay renders LoginWorld, which starts three
requestAnimationFrame loops on mount. Under the load of the full suite that
was enough to push the overlay assertion past waitFor's default timeout.
Nothing in this file asserts on the animation, and LoginWorld has its own
tests, so the file stubs it.
2026-09-04 20:29:39 +02:00
Maurice 981cf0a143 test(maps): pin the RTL registration, and correct the precache figure
The guard in rtlText.ts had tests; the two lines that call it did not.
setRTLTextPlugin appears at exactly two places in the client and no test
touched either, so deleting a call, swapping an argument or dropping the
deferred flag left the whole suite green. The engines are mocked because
importing them for real builds a worker pool under jsdom, and the plugin
URL is mocked so the payload never loads.

The precache comment in vite.config.js was three releases stale, and it is
the only record in the repo of what a service-worker install costs. A real
build says 463 entries / 23,292 KiB, the plugin among them: it matches the
js glob, so it is fetched at install by every user rather than on first RTL
text as the branch description claims. That trade is the right one, since
the alternative is a CDN the production CSP refuses and an unshaped map for
anyone offline. Only the description of it was wrong.

Measured against a real client build, which no CI workflow performs: none
of them runs vite build or check:gl-split, so nothing here was covered
before.
2026-09-04 19:53:33 +02:00
Mohamed Elkmeshi dc7d1f5e10 fix(maps): register the RTL text plugin so Arabic labels join
Vector basemaps rendered Arabic, Hebrew, Persian and Urdu place names as
isolated letters in logical order — "القاهرة" drawn as "ة ر ه ا ق ل ا" —
because neither GL engine loads its optional RTL text plugin by default and
TREK never registered one. Raster basemaps hid the problem, since their
labels are drawn server-side, so it only became visible for everyone when
the default basemap became a vector style.

Both engine entry points now register the plugin lazily, so the payload is
only fetched once a map actually paints RTL text and Latin-only users
download nothing. The call is left with each engine because the two
signatures genuinely differ: MapLibre takes (url, lazy) and returns a
promise, Mapbox takes (url, callback, deferred).

The plugin is bundled rather than loaded from a CDN: TREK is offline-first
and the production CSP is script-src 'self', so a remote plugin URL would be
refused by the worker that imports it. Bundling also puts it in the Workbox
precache, so shaping keeps working offline. It needs a resolve alias because
the package publishes only its ESM/WASM entry point through "exports" while
setRTLTextPlugin requires the classic script in dist/ — the alias is shared
by the build and test configs so the two cannot drift.
2026-09-04 19:53:33 +02:00
Maurice 6f057ab9f4 fix(reservations): keep a mirrored booking price without keying it on the expense
The carry-over was conditional on a budget_items row still being linked to
the booking, which drops a real price in two shipped cases. The booking
importer writes metadata.price unconditionally but only creates the linked
cost when the Costs addon is on and the price is above zero, so an instance
with that addon off has bookings carrying a price and no budget row at all;
an MCP booking created with a price of 0 is in the same position. Both lost
the price on the first edit.

The condition is gone: a payload that does not name the key keeps what is
stored, and naming it, null included, is taken at face value so a price can
still be removed on purpose.

What stops a price from outliving its expense is now the other end.
deleteBudgetItem is the single chokepoint for all four delete paths, and it
clears the mirror there, inside the same transaction as the delete. That
also removes an ordering hazard the linked-row check had: the controller
runs update() before it syncs the budget, so on an explicit clear the row
still existed and the price would have come back a moment before it went.

The predicate moves into reservation-metadata.ts so it can be read and
tested on its own, and the update_transport tool description no longer
promises that metadata is replaced wholesale.
2026-09-04 19:08:15 +02:00
Xheldon ce58de29a5 fix(reservations): keep the linked expense price when a booking is edited
A booking's linked expense price lives in reservations.metadata.price
(written by BudgetService.syncReservationPrice). The booking form rebuilds
metadata from its fields on every save and only carries transit /
airtrail_ids over, so PUT /api/trips/:tripId/reservations/:id overwrote the
stored metadata and the price vanished from the card until the expense was
re-saved.

ReservationsService.update() now carries price / priceCurrency from the
stored metadata into an incoming metadata object that does not set them,
as long as a budget item is still linked to the booking. A payload that
sets price itself, a booking without a linked expense, and an explicit
metadata: null behave exactly as before.

Closes #2233
2026-09-04 19:08:15 +02:00
Maurice c5208967ac fix(app-config): refuse an LLM ceiling that would become a zero timeout
LLM_TIMEOUT_MS was validated as "a positive number", which lets two shapes
through that then degrade into an immediate abort rather than a long one.
A fractional value passes the check and floors to 0 in derive, which makes
the dispatcher fall back to undici's default and setTimeout(abort, 0) kill
every model call on the next tick. Anything past 2^31-1 does the same,
because Node clamps the delay to 1 ms. Both now refuse at boot, which is
what the wiki row already promised.

Also pins the seam the three deleted constants used to occupy: the clients'
abort deadline has to come from the configured ceiling. Nothing else in the
suite would have noticed a literal coming back.
2026-09-04 18:56:22 +02:00
Maurice 53c246e8e5 docs(wiki): the AI parsing addon now reads one environment variable
Three shipped sentences became false with LLM_TIMEOUT_MS: two claiming the
addon needs no environment variables (one of them in the very file the
variable was added to), and one naming the old five-minute ceiling that the
deleted constant used to set.
2026-09-04 18:56:22 +02:00
RemEx dba4434ef6 fix(llm-parse): one configurable ceiling for a model call, and undici honours it
The timeout for a model call was spelled three times and adjustable
nowhere: 120s in the Anthropic client, 300s in the OpenAI-compatible one,
300s in the Ollama one. None of them was the ceiling that actually
applied, either — every one of those calls goes through the pinned undici
dispatcher, and an undici Agent stops waiting for response headers after
five minutes by default, so a client constant set above that never got
the chance to fire.

- LLM_TIMEOUT_MS (default 900000) replaces the three constants and is
  passed to the dispatcher as headersTimeout/bodyTimeout, so the abort
  signal and undici are governed by the same setting. The default is
  deliberately generous: heavier parsing work should fit without a code
  change, and an admin who would rather fail fast can lower it.
- The ceiling stays on the model lane. safeFetchAdminConfigured is also
  the OIDC lane (discovery, token, userinfo, JWKS) and the plugin OAuth
  token exchange, and that one sends no AbortSignal of its own, so it
  keeps undici's five minutes rather than inheriting a model setting.
- The derived value is floored: the schema accepts any positive number
  and undici rejects a fractional headersTimeout.
2026-09-04 18:56:22 +02:00
Maurice 55f14c6a70 fix(offline): the PWA starts offline again and the download does what it says
Four separate defects sat behind "offline mode is broken".

The app shell. On the first online launch after a version bump, App.tsx
deleted EVERY Cache Storage bucket and unregistered EVERY service worker,
then reloaded. That is exactly wrong for a Workbox autoUpdate build, which
swaps its precache atomically on install and never leaves an empty one.
Between the purge and the completion of a fresh 22 MB precache the origin
had no shell at all; anyone who closed the app or lost signal inside that
window could no longer start the PWA offline. It also threw away the map
tiles and documents the user had downloaded on purpose. It now asks the
worker to fetch the new build and reloads once it has taken over. The
version marker also moves inside the guarded path, because it was written
after the purge and outside its try: a throwing setItem left the caches
gone, the marker unwritten and the reload unreached, repeating on every
launch.

The reported version is validated before it reaches this device's storage and
before it is compared against the stored marker, so a value that is not a
release tag can neither be persisted nor start an update on every launch.

The session. loadUser's offline branch kept the persisted session but
never called onAuthSuccess, the only caller of setAuthed(true), and the
mutation queue's flush, syncAll and prepareForOffline are all gated on it.
A session that launched without network could therefore never sync again
for the rest of its life, even after the signal returned: the offline
settings buttons spun and returned instantly having done nothing, and
queued edits never uploaded. It also left reads pointed at the anonymous
database instead of the user's own.

The date rule. A trip is only cached when it has not ended, and one that
ended over a week ago is actively evicted. The per-trip switch was an
opt-out list, so ticking a finished trip changed nothing at all. For
anyone whose trips are all in the past, "Download for offline use" stored
none of them, reported success, and the cache stayed at zero. Switching a
trip on now pins it, which overrides both the skip and the eviction, and
the switch shows the honest position rather than promising storage that
never happens.

What the screen says. A sync that never started (already running, offline,
signed out) was indistinguishable from one that stored everything: the
progress bar painted full and a green "Ready for offline use" appeared over
a run of nothing. Both syncs now report why they stopped and the screen
says so. A rejected Dexie read no longer renders as "No trips cached yet",
and the clear button stays reachable when the database holds data that no
bookkeeping row points at. A run that rejects outright says so too: the trip
list it starts from is a plain fetch, so a network blip took the whole thing
down and left the last progress frame on screen looking like a finished
download. The 401-with-HTML branch in the API client now
confirms a reachable proxy before tearing the worker down, since several of
TREK's own routes answer 401 with a text/html body.

Both settings shells move onto one shared hook rather than growing the same
logic twice, and the tile prefetch caps its depth when the browser refused
persistent storage. Opaque tiles are billed at about 7 MB each, and an
origin that is not exempt from eviction loses its whole bucket, precached
shell included, which Workbox then never refills because sw.js has not
changed.

Closes #2228
2026-09-04 14:32:44 +02:00
Maurice 6d54ac629e fix(oauth): accept private-use redirect schemes and loopback ports
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.

The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.

Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.

Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.

Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.

Closes #2227
2026-09-04 12:15:53 +02:00
Maurice bafcd456c5 fix(collab): notes show and keep their website on the phone
The note website round-trips correctly through the API and the desktop
grid renders it, but the mobile notes tab never carried the field: the
form had no input, the payload had no key, and neither the card nor the
read-only sheet showed a stored link. Since an editor only ever opens the
form and a viewer only ever opens the sheet, no role on a phone could see
or reach a website saved from a desktop.

Editing a note on mobile did not wipe an existing link. The key was
omitted rather than nulled, and the server's CASE WHEN guard kept the
stored value, so this was a visibility gap rather than a destructive one.

The form now always sends the field, null included, because the server
only clears on an explicit non-undefined value. Every render path goes
through safeExternalHref: the REST contract accepts any string, so an
unguarded href would execute a stored javascript: value in this origin.

The desktop expanded view had the same omission and is fixed alongside it,
so a member without collab_edit no longer loses the link there either.

Closes #2222
2026-09-04 12:15:38 +02:00
Maurice 9ed88adf34 fix(budget): an expense with no payer creates no debt
An expense saved with "No one paid yet" was still charging its split
members in calculateSettlement while crediting nobody, so the ledger
drifted off zero by the unpaid total. The greedy simplifier that turns
balances into payments has no memory of which expense produced which
debt, so it handed that phantom amount to whoever happened to be a
creditor from an unrelated expense. In the report, two people each owed
about 80 to someone who never paid that bill. Booking every payment it
offered then left one member permanently short, next to an "everyone
square" line.

Both cost panels already promise the opposite: they label such a row
"Unfinished, total only, not settled yet" and count it into the
Outstanding amount card instead. The server now agrees with them.

The guard deliberately does not also require a non-zero total, unlike the
panels' label predicate. PUT /budget/:id/payers with an empty array zeroes
total_price without re-applying it, which would otherwise leave a custom
split debiting its members against no credit at all.

The #1543 fixture and BUDGET-SVC-DB-004/005 were re-baselined: they pinned
the old arithmetic through a deliberately payer-less 9 000 RUB item. The
wiki said the old behaviour was intended, so it is updated too.

Closes #2225
2026-09-04 12:15:38 +02:00
jubnl bc10fba389 feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning

A plugin whose author has not updated its `trek` range for the running TREK can
now be installed, updated and activated when the operator sets
TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker,
post-extraction re-check, sideload, dev-link, activation) warns instead of
refusing; a missing range is tolerated the same way; "install latest" takes the
newest published version. The plugin-API version gate is not lifted and the
Discover compatibility verdict stays truthful.

The admin is warned at every step: each bypass is logged, install/upload/link/
update responses carry a `trekRangeBypassed` marker, and both admin panels show
a "Version checks off" pill, an "Install anyway" confirm dialog before a
registry install, the same notice after a path that could not ask first, and a
persistent chip on the row while the plugin runs outside its range.

Nine new i18n keys in every locale; wiki pages updated (Environment-Variables,
Admin-Plugins, Plugin-Development, Security-Hardening).

* refactor(plugins): one useRangeBypass hook behind both admin shells

SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass
logic had been added to the desktop and phone plugin panels as identical
copies. The marker/warning types, the row chip, the "Install anyway" offer,
the dialog copy and the confirm-or-notice state now live in
useRangeBypass (next to useInstanceSettings, the existing shared-logic
precedent); each panel keeps only its own dialog/sheet markup.

* docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule

The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3%
duplication, A ratings, hotspots reviewed) was only discoverable by waiting
20-25 minutes for the workflow. The root CLAUDE.md now states the conditions,
the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and
how to check a PR with the sonarqube MCP first; client and server CLAUDE.md
point at it. shared/CLAUDE.md says every locale needs a real translation —
parity cannot tell an English placeholder from a translation.

* docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy

Verified every concrete claim in the five CLAUDE.md files against the code
and fixed the stale ones: the websocket server is the Nest realtime gateway,
MCP tools live in <domain>.mcp.ts files, the client "native shell" section
described code that was never written, offline maps also prefetch vector
basemaps, plus several renamed files and wrong table names.

Then trimmed everything that drifts on its own: dates, migration history,
counts, and inventories of stores, slices, domains, sync files and CLI
commands. What stays is the commands, the invariants with the file that
enforces each one, what CI actually runs, the reference implementations to
copy, and the rules for new code. Each file now opens with that rule.
2026-09-04 00:43:06 +02:00
Maurice 74c47a6158 docs(wiki): add the feature-requests banner used by the release notes
Release notes link the banner from the repo rather than from a comment
attachment, so it keeps working when the notes are edited later.
2026-09-03 16:18:21 +02:00
Maurice 8b291a9a7e fix(maps): handing a place to a maps app leaves no blank window behind (#2218)
In an installed app, opening a maps target in a new context left an empty
window: the platform switches to the maps application before that context
paints anything, and coming back lands on a blank page the user has to
dismiss before TREK is usable again. The handover now happens from the page
the user is already on when TREK runs as an installed app, which is where
there is no tab strip to close a stray window from. A browser tab still opens
a tab, because there a link should.
2026-09-03 15:51:13 +02:00
Maurice 07e4a558e6 fix(bookings): the phone keeps a booking's stop and shows the files it already has (#2216, #2217)
Editing a booking on the phone erased the stop it was linked to. The sheet
seeded its assignment from the create-for-a-stop flag, which is null on an
edit, and saved that straight back over a link the user had made on the
desktop; the server writes the field whenever the key is present. It now
seeds from the booking being edited, and the picker the desktop dialog has
always had sits in the sheet too, so a stop can be seen, changed and cleared
from a phone. The option list moved to its own module rather than being
copied.

Two more of the same shape, found on the way. The endpoint set was sent as an
empty list on every save, and the server swaps the whole set when that key is
present, so editing a transit booking dropped its stations; neither form
edits endpoints, so an edit no longer sends the key at all. And the sheet's
Files block listed only what was picked in this session, so a file uploaded
from the desktop was invisible on the phone. It lists what is attached now,
resolved the way the desktop resolves it.

A place also shows the files of the bookings that sit on it, on the phone and
on the desktop: a confirmation lives on the booking, and the place card was
the one screen that could not reach it.
2026-09-03 15:51:13 +02:00
Maurice 9228ef2bce fix(notifications): a real send keeps its ten minutes, only the admin test is impatient (#2196)
The #2196 timeouts were applied to one shared transport, so the 30-second
inactivity bound landed on the password-reset and notification mails as well
as on the admin's test send. That bound starts counting after the greeting,
which means it bounds the transfer: a relay that scans the message can sit on
DATA for minutes, and a reset mail dropped for that reason fails where nobody
can see it. Nodemailer's ten minutes are back for a real send, the connect and
greeting cuts stay, and the test send keeps the 30 seconds it needs to answer
inside the client's 40-second budget. The source comment already described this
split; only the value disagreed.
2026-09-03 13:59:39 +02:00
Maurice 15f22058e9 refactor(studio): split the content panel and cover the new paths
Three things Sonar was right about. The content panel had grown a filter, a
menu and an upload into one function of thirty-two branches: the filtering
is four pure rules in photoFilter.ts, the row that drives it is its own
component, and what is left in the panel is the grid and the drop zone. The
gallery rows behind an entry upload move out of the store's setter into a
named function, since five levels of nesting is a place bugs hide. And the
ids for placed elements come from getRandomValues rather than Math.random,
in one module instead of four copies of the same line: two editors minting
the same id for two elements is one of them losing their work.

The new paths carry tests now, which is what the gate was really asking
for: the filter's rules, the ids, the upload helper, the store's derived
gallery rows, the canvas file drop, and the shell's upload, drop and stop
switch.
2026-09-03 10:47:08 +02:00
Maurice b383230928 fix(studio): fold the stops list and put a picture into the gallery it lands in
The stops list was the longest thing in the travel panel by a distance: a
fortnight is fourteen rows, and it pushed the maps and the country tiles
below the fold on every journey. It is a folding section now, closed to
begin with, with the count of what still counts in its head, so the closed
section answers the question anyone opens it for. The rows are one line
each, the day loses its weekday, the country appears only on a journey with
more than one, and the switch is a mark rather than a word, which is what
lets the place name fit.

A picture uploaded onto an entry also goes into the gallery, because that is
where the row lives. The entry upload only answers with the entry's view of
it, so the store derives the gallery row it did not get and the content
browser shows a new picture without the journey being fetched again.
2026-09-03 10:47:08 +02:00
Maurice 6cbefd23e8 feat(studio): filter photos by entry, upload from Studio, and switch stops off
The content browser gets a filter over the journey's pictures: all of them,
the ones no entry holds, or one entry at a time, with the day and the place
under each name. An entry card in the Entries tab reaches its own pictures
the same way. Pictures can now come in without leaving Studio, into the
entry the filter names or into the gallery, from the panel or dropped
straight onto the page, and a photo element can fill its page or the whole
spread in one press.

Stops can be taken out of the arithmetic. A journal entry carries a
stats_excluded flag; an entry that has it is off the route, out of the
distance, out of the countries and not a step, while staying in the
journal. Studio lists every stop in the travel panel with a switch, the
entry editor carries the same switch on desktop and phone, and the entry
card says when a day is off the route.
2026-09-03 10:47:08 +02:00
dependabot[bot] 23401fcdb9 build(deps): bump fast-uri from 3.1.5 to 3.1.7
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
(cherry picked from commit 56f7e1871f97a2059a47f8b57adb785568d3c861)
2026-09-03 10:07:27 +02:00
Maurice a2cb6a12d1 fix(mobile): a stay chip in the timeline header opens the stay, not the day (#2210)
The hotel chips next to the day pill shared its target, so tapping a stay
opened the day sheet. A chip now carries its stay and opens it the way the
stay card in the day sheet does: the accommodation editor for members who
may edit days, otherwise the hotel's place, and the day sheet only when
neither applies. Closing the editor returns to the timeline it was opened
from instead of surfacing the day sheet, and the chip's accessible name
says whether it is a check-in, a check-out or an ongoing stay.
2026-09-03 09:15:27 +02:00
Maurice ffb6546e51 fix(mcp): prompts take the trip id as the string every client sends (#2207)
Prompt arguments cross the wire as strings, but the three trip prompts
declared tripId as z.number(), so every prompts/get failed with -32602
"expected number, received string", MCP Inspector included. The id is now
parsed from the string through a shared tripIdPromptArg (still an integer
in the safe range), PromptOptions only admits string-input schemas so the
mistake cannot come back unnoticed, and a prompt declared with an empty
argsSchema no longer demands an empty arguments object from the client.
The prompt tests run through a real in-memory MCP client instead of
calling the callbacks directly.
2026-09-03 09:15:14 +02:00
jubnl b82962f95b Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors

* feat(plugin-sdk): instance-scoped settings actions

* feat(plugins): run instance-scoped actions from the admin settings dialog

* feat(admin): instance-scoped plugin actions in the settings dialog

* fix(plugins): surface instance actions without instance fields and harden the action gate

* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope

* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window

* docs(wiki): plugin pages for instance actions and the admin panel changes
2026-09-02 23:06:03 +02:00
Maurice b1ce7c303d feat(todo): sort the task list by due date (#2205)
The list held its manual order, so an item due tomorrow sat behind one created earlier but due later. The sort-by section gains a due-date toggle next to the priority one, on desktop and in the phone tab: nearest deadline first, undated tasks after all dated ones, ties keep the manual order. One sort at a time, and manual drag-reorder pauses while either is active, exactly like the priority sort.
2026-09-02 21:39:01 +02:00
Maurice c5d10dfd6f fix(client): settle the two findings Sonar raised on the audit branch
The farthest-stop reduce gets its explicit seed (the guard above already refuses an empty list, the seed makes that local), and the toast pill moves into its own component so the host stays below the complexity ceiling.
2026-09-02 21:06:51 +02:00
Maurice ed3216f65d fix(planner): an untimed hotel bookend only closes a local day-trip loop (#2157)
Without stay times the bookend leg drew unless a carrier or a 2000 km edge stop disproved it, which is exactly the reported car trip: home five hundred kilometres away, routed back to the hotel after check-out. The disproof threshold is a day-trip distance now, and the route optimizer anchors through the same rule on desktop and mobile instead of unconditionally, so drawing and optimizing agree.
2026-09-02 21:06:51 +02:00
Maurice 31fa8908fc fix(weather): one failed fetch no longer parks the badge on the dash (#2167)
failed was set once and never cleared, so a single hiccup kept the badge on its error dash while later fetches quietly delivered data. It resets with every new anchor.
2026-09-02 21:06:51 +02:00
Maurice 1428637694 fix(mobile): the filter pill hugs its chips and the list switcher clears the dock (#2104)
On wider phones the dashboard filter pill stretched across the leftover width instead of wrapping its chips. The collections list switcher could not scroll, so its lower entries and the new-list button hid behind the dock.
2026-09-02 21:06:51 +02:00
Maurice 3f49ef8d21 fix(plugins): readable save button, honest respawn, scrolling hosts dialog (#2170, #2159)
The instance-settings save button paired bg-accent with a hard text-white and vanished in dark mode; it uses the accent pair token now. A failed activate() after saving no longer reports success over a dead plugin: the config stays, the response says what happened. The allowed-hosts dialog scrolls like the ones e70d7397 fixed, and the discover cards get the press-scale hardening from #2158, on desktop and on the phone panel.
2026-09-02 21:06:51 +02:00
Maurice 348d584a02 fix(collab): sanitized markdown keeps angle-bracket text and working footnotes (#2177)
rehype-sanitize silently dropped anything that parsed as an unknown tag, eating text people had saved long before the sanitizer arrived; disallowed markup renders as literal text now. GFM footnotes pointed at a doubly prefixed anchor and never landed. One shared plugin set drives the polls, the notes, the note card and both mobile tabs.
2026-09-02 21:06:51 +02:00
Maurice 4cd25d3047 fix(admin): the addons page re-reads after a journey toggle instead of trusting itself (#2156)
Disabling Journey cascades the photo providers off on the server, but the panel kept its local rows, so after off and on the providers showed active while being disabled. The desktop and the mobile panel re-read the list after a journey toggle; the write itself still rolls back a single row on failure.
2026-09-02 21:06:51 +02:00
Maurice 293dc9e1f1 fix(ui): defuse the press-scale click trap on three more composite buttons (#2158)
Same trap the task-list fix hardened: the press animation shrinks the container mid-click and the release lands next to the target. The collections rows, the journey gallery tiles and the shared vacay calendar cards carry the established no-press attribute now.
2026-09-02 21:06:51 +02:00
Maurice c026c5b1c9 fix(auth): passkey_configured stops advertising the phantom localhost config (#2147)
Without any configuration getAppUrl() invents http://localhost as the relying party, and the UI advertised passkeys that every click answered with not configured. A localhost RP only counts as configured now when the operator declared one. The origin check also binds to the expected origins again instead of accepting every subdomain of the RP id; that widening never shipped in a release.
2026-09-02 21:06:51 +02:00
Maurice a5d87d295a fix(settings): the offline language mirror does not outlive the account (#2146)
The mirror of the account language stayed in localStorage past logout, so the next account on the device booted in the previous user's language forever. It is cleared on logout and rewritten on login, and the rate-limit message follows the same order the boot language does.
2026-09-02 21:06:51 +02:00
Maurice 7c857173cf fix(costs): three-decimal currencies stay typable and refunds work on iOS (#2175, #2176)
The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total.
2026-09-02 21:06:51 +02:00
Maurice c907e0d5e5 fix(packing): bag totals survive reconnects and MCP bag deletes (#2191)
Deleting a bag over MCP moves every figure, the items fall into the unassigned pile, yet it sent no totals ping; it pings the room now like REST and the plugin RPC do. The client refetches totals after a room re-join and when coming back online, through the same coalescing window. The plugin SDK types learn the create fields the routes accept since #2154, on both hand-kept declarations.
2026-09-02 21:06:51 +02:00
Maurice 9cc427d156 fix(journey): gallery actions on tablets, tracks switch on phones (#2194)
Between 768 and 1023px the view-controls row is hidden and the provider and upload buttons had moved into it, leaving no way to add photos; the gallery actions render below the floating bar there now. The mobile journey settings sheet gets the linked-trip tracks switch the desktop dialog got, and the request gate and the fitBounds behaviour are pinned by tests.
2026-09-02 21:06:51 +02:00
Maurice 088eef6c6b fix(mobile,map): long toasts wrap and http selfhosts get the right geolocation message (#2095)
The phone toast was a single truncated pill, which cut the denied message exactly before the settings hint it exists for; long messages wrap up to four lines now. An insecure context is reported as its own error instead of masquerading as a blocked device permission.
2026-09-02 21:06:51 +02:00
Maurice e1af2049f9 fix(atlas): the note tooltip scrolls instead of zooming the map (#2153)
Leaflet's wheel handler on the map container cancels the native scroll, so the wheel zoomed the world under the note; the tooltip stops scroll and click propagation now. Hovering back to the marker no longer closes the tooltip mid-way, the flip threshold matches the real tooltip height, and a touch swipe scrolls the note instead of panning the map.
2026-09-02 21:06:51 +02:00
Maurice 5feced54d2 fix(studio): actually load the five book fonts the picker offers (#2183)
The @fontsource packages for Inter, Lora, EB Garamond, Playfair and Bebas were declared but never imported, so every pick fell through to its fallback stack, in the print path too. The faces load with the lazy Studio chunk now, in the exact weights BOOK_FONTS uses, including the MuseoModerno 500/600 the picker offered and nobody shipped. A guard test derives the required imports from the registry.
2026-09-02 21:06:51 +02:00
Maurice 2ab2dcc3dc fix(offline): the osm-de and stadia prefetch was CSP-blocked and the opening view stayed grey (#2180)
connect-src never listed tile.openstreetmap.de or tiles.stadiamaps.com, so every prefetch fetch was refused before the service worker saw it, while the swallowed error still counted as a cached tile. Both hosts are allowed now and only reached tiles count. The prefetch rectangle is also widened to the screen the fitBounds view fills, on the raster and the vector path alike; the places extent alone left grey bands beside a north-south trip.
2026-09-02 21:06:51 +02:00
Maurice fc6843528c fix(calendar): a fully timed stay keeps its description and location (#2136)
The markers replaced the booking's block but carried none of what it said: no DESCRIPTION (type, confirmation, notes) and no LOCATION fallback. The carrying booking is resolved once now, and the markers inherit its description and location exactly when the block is dropped. A check-in window ending before it starts reads as a late arrival past midnight instead of swallowing the event.
2026-09-02 21:06:51 +02:00
Maurice faa27e3608 fix(planner): a place shows every booking linked to it, not just the first (#2201)
Four render sites resolved the day assignment's booking with find(), while the booking dialog happily links several to the same stop. A shared selector in dayMerge returns them all, time-sorted and stable, and the desktop plan tab, the place inspector, the mobile timeline and the mobile place sheet render the full list; the single-booking case is unchanged pixel for pixel.
2026-09-02 21:06:42 +02:00
Maurice 5d849ec292 fix(journey): the gallery runs in trip chronology instead of upload order (#2200)
journey_photos.sort_order is nothing but the insert counter, so photos added late landed behind days they belong before. Both gallery readers (the app and the shared journey) order by capture time now, falling back to the timestamp of the stop the photo belongs to and then to upload time, through one shared SQL fragment; ties keep the old order, so existing galleries only move where chronology demands it. The order inside an entry stays the user's manual arrangement.
2026-09-02 21:06:42 +02:00
Maurice b6a86275f6 fix(notifications): the smtp test names its failure and the log carries it (#2196)
testSmtp logged nothing and the transport ran on nodemailer's two-minute defaults while the client gave up after eight seconds, so every failure was a generic toast over an empty log. The transport gets real timeouts, every outcome is logged without credentials, and a classified, scrubbed cause (auth, DNS, refused, filtered port, TLS mismatch, certificate) goes into the response the admin sees; the channel test calls wait long enough to receive it. The password field also rendered its mask as value, so replacing a stored password silently saved the bullets as part of the new one; it is a placeholder now, on desktop and the phone admin.
2026-09-02 21:06:42 +02:00
jubnl 1e6ab069cd Fix/sdk default (#2199)
* feat(plugin-sdk): settings-field default + unknown-attribute warning

* feat(shared): default on the plugin settings-field contract

* feat(server): persist and serve plugin settings-field defaults

* feat(client): pre-fill plugin settings defaults in the admin and user forms

* feat(server): enforce required plugin settings on both save paths

* feat(client): block plugin-settings save on an empty required field

* feat(server): resolve plugin settings defaults at runtime

A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.

Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.

* feat(client): share the plugin settings form rules across all three forms

The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.

* feat(plugin-sdk): seed dev fixtures from manifest defaults

`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.

* docs(plugins): document settings defaults as runtime fallbacks

A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.

* add missing exports
2026-09-02 18:42:16 +02:00
Claude 17668e5ff2 test: cover the new code paths SonarCloud flagged as unmeasured
The quality gate came in at 79.5% coverage on new code against a floor of 80%.
The gaps were real ones rather than a threshold quibble: three pieces of new
behaviour had no test at all.

- `useBagTotalsPing` was a new file with no test, and it holds the logic that
  stops one write turning into a storm of listBags requests on every connected
  client: the coalescing window, the in-flight guard, and the ping that arrives
  mid-flight and must still be served.

- The Journey Settings tracks switch had no test, including the part the review
  caught: that it refreshes rather than calling onSaved, which would close the
  dialog and drop unsaved edits.

- Neither bag panel had ever been rendered in a test. They now pin what the
  panels actually print: the server total rather than the sum of what the
  viewer can see, the grand total following the same rule as the rows above it,
  and the offline fallback.

Also covers the two ping call sites that were still unmeasured, the MCP
apply-template path and the plugin RPC bag delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude 700918d533 test(packing): pin the bag-totals ping in the RPC and MCP fan-out expectations
Three plugin-RPC tests and the MCP bulk-import test assert the exact list of
broadcasts a write produces, so adding the #2191 weight ping to those two
surfaces made them fail — correctly: they are the contract for who hears what.

Updated to include it, and asserting it rather than just tolerating it. The
private-item cases are the interesting ones: the item event still reaches only
its viewers while the weight ping goes to the whole room, which is precisely
what makes a content-free ping safe there.

The bulk import gets one ping for the whole import rather than one per item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude 98b97edb00 fix(packing,journey,server): address review findings on the three fixes
An adversarial review of the diff found sixteen defects that survived
verification. The substantive ones:

- The new `packing:bag-totals` event was never classified in wsEventPolicy,
  so the registry-parity ratchet was red. It belongs in
  HANDLED_OUTSIDE_TRIP_STORE: it carries no state to apply, which is the
  point of it.

- The Journey Settings tracks switch called onSaved(), which closes the
  dialog. That destroyed the switch the moment it was flipped AND skipped
  the unsaved-changes guard, so a title typed but not saved was silently
  lost. It refreshes instead.

- Deleting a bag sends no totals ping, yet packing_items.bag_id is
  ON DELETE SET NULL: everything in the bag lands in the unassigned pile and
  both figures move. The comment claiming otherwise was wrong.

- Only the REST handlers pinged. The MCP tools and the plugin RPC are two
  more live write paths, and since the displayed number now comes from the
  server rather than from the item events, weights simply stopped updating
  for anything written through them.

- Every item write pinged, including checking an item off — the most
  frequent packing write there is — and clearing twelve checked items sent
  twelve. The ping now fires only when a weight-relevant field changed, and
  the clients coalesce bursts and never overlap two refetches.

- Bags have no repo and no Dexie table, so offline the server totals froze
  at the last online read and went blind to the mutation queue. A stale
  absolute number measured against an airline limit is worse than an honest
  partial one, so offline the surfaces sum what they can see.

- SIGTERM arriving mid-bootstrap called server.close() on undefined, and the
  fire-and-forget promise turned that into an unhandled rejection: on Node 22
  a crash, which is the opposite of the fix.

- Promise.allSettled swallowed a rejected nestApp.close(), reporting a clean
  exit 0 while the crons and the plugin children were never torn down. This
  bug class is diagnosed from container logs.

Also: the track fetch gate read a stale `current` during journey-to-journey
navigation and fetched the previous journey's tracks; listBags ran the weight
aggregate twice per request, on a route the ping now fires constantly; the
grand total counted unassigned weight whose row was not rendered; the client
declared show_trip_tracks boolean while the wire carries SQLite 0|1; and the
six broadcast calls had no test, so removing all of them left the suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude 639c40f8de fix(packing): a bag weighs what everyone put in it, not just what you can see
A bag's weight was summed in the browser over the item list the viewer is
allowed to see. Personal items belonging to other members are filtered out of
that list by design (#858), so their weight vanished with them: two people
looking at the same shared bag saw two different totals, and neither saw the
real one.

That number is then measured against `weight_limit_grams`, an absolute airline
limit. A per-viewer subtotal there is not merely incomplete but wrong — a
shared bag could sit over its limit and warn nobody.

The sum now happens in SQL over every row and ships as `total_weight_grams` on
the bag, with `unassigned_weight_grams` alongside so the "no bag" pile and the
grand total follow the same rule; a screen mixing true totals with per-viewer
ones would be worse than either. Only integers cross the wire: a member learns
that a bag is heavier than the items they can see, never a name, category,
quantity or owner. That disclosure is deliberate and bounded, and it is what
the issue asks for.

The item lists stay viewer-scoped, so #1767 ("someone shared it with you, but
they are the one bringing it") is unchanged — it just no longer decides what a
bag weighs.

Totals also had to stay live, because the reported repro is two people looking
at the same screen. A private item's event reaches only its owner, which is the
very rule that made the totals wrong, so item writes now also ping the room
with a content-free `packing:bag-totals` and everyone re-reads the numbers.

All three surfaces — the bag sidebar, the bag modal and the mobile sheet — read
the server figure and fall back to the local sum only for a bag cached offline
before this change.

Closes #2191

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude b7f9c1e8ae feat(journey): make the linked trips' GPX tracks a switch, off by default
#1260 drew the GPX tracks of every trip behind a journey onto its map, with
nothing to switch off — the reasoning being that a route imported into a
linked trip is part of the journey's story. For a trip carrying a season of
recorded drives it is instead a map nobody asked for, drawn from places that
never became an entry.

So it becomes a journey-level setting, owner-only like the other Journey
Settings, and it starts off: a journal should show what its author put in it,
and #1260's set is everything the linked trips happen to contain. The switch
gates the request, not just the rendering — the endpoint ships full unthinned
geometry, which is megabytes fetched on every journey open for a map nobody
wanted.

`journeyTracks()` itself is deliberately untouched, so TREK Studio keeps the
set its saved books were laid out with.

Also fixes what the reporter's screenshot actually shows: JourneyMap fed track
coordinates into `fitBounds`, so one drive across a country zoomed the journey
map out until the entries were specks. Tracks now only frame the map when
there is nothing else to frame, which is what JourneyMapGL already did — the
same journey used to open at two different zooms depending on the renderer.

Closes #2194

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude 5e5fe288d8 fix(server): release sockets on shutdown so docker stop no longer ends in SIGKILL
Every `docker stop` of a TREK instance with a browser attached exited 137,
exactly ten seconds after the stop.

`http.Server.close()` stops new connections and then waits for the open ones
to end by themselves — and a WebSocket never does. TREK holds one per open
tab, so the close callback never ran. `closeDb()` and `process.exit(0)` lived
inside that callback, which left the only remaining exit a fallback timer set
to 10000ms: exactly when Docker sends SIGKILL. Docker starts counting when it
sends the signal and Node only once the handler is dispatched, so that timer
started strictly later and could never win. It was unreachable code in every
default deployment.

Waiting for Nest was not an option either: `WsAdapter.close()` is the one
place that terminates clients, and Nest reaches it only after every
onModuleDestroy hook has resolved — including the plugin supervisor's SIGTERM
grace for its forked children. The sockets holding the server open would be
hostage to the slowest hook in the container.

So the sockets are now released deliberately: ws clients get 1001 "going
away", idle keep-alive sockets are dropped at once, and anything still
attached after a 2s drain is destroyed. Nest is closed alongside that, the
database is closed on both exit paths rather than only the unreachable one,
and the fallback fires at 5s — inside Docker's grace, so a forced exit is
still ours.

The sequence moved into src/shutdown.ts behind injected dependencies. It was
untestable by construction before, which is part of why this went unnoticed;
it now has nine tests, including the signal that beats the bootstrap.

Closes #2193

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Maurice 2d460eadfb fix(pwa): dedicated safe-zone maskable icons for android launchers 2026-09-01 21:12:27 +02:00
Maurice 33e737e453 fix(mobile): keep the dashboard filter row from widening the layout viewport 2026-09-01 21:12:27 +02:00
Maurice 46a89e7a90 fix(map): surface geolocation failures as a localized toast 2026-09-01 19:24:38 +02:00
Maurice cfe2b80a5d fix(collections): mobile map fills the viewport like trip and journal maps 2026-09-01 19:24:38 +02:00
Maurice da2c881259 feat(admin): ai-parsing settings live in the tile shelf, compact 2026-09-01 18:18:08 +02:00
Maurice 5decdcfa13 fix(offline): cache osm-de and stadia tiles, prefetch down to the opening zoom (#2180) 2026-09-01 17:58:08 +02:00
Maurice 1e6f1fe95c fix(studio): text elements use the real font stacks, not the stale three-font map (#2183) 2026-09-01 17:58:08 +02:00
Maurice 674dededd3 Merge remote-tracking branch 'origin/main' into dev-mergeback
# Conflicts:
#	server/tests/unit/nest/calendar.service.test.ts
2026-09-01 17:21:23 +02:00
Maurice 5f63736b5d test(pages): wiring mocks track the new gallery-provider and weather-anchor contracts 2026-09-01 17:18:10 +02:00
Maurice 2d6454976e test(auth): passkey controller mocks need request headers now 2026-09-01 17:18:10 +02:00
Maurice c4fbf03928 fix(planner): guard the post-drag click swallow against a torn-down document 2026-09-01 17:18:10 +02:00
Maurice 03bd5c77af fix(weather): day-local named anchor, cache ttl and past-days forecast window (#2167) 2026-09-01 17:18:10 +02:00
Maurice c21e86e3bd feat(planner): surface and edit day-assignment notes across desktop, mobile and pdf (#2163) 2026-09-01 17:18:10 +02:00
Maurice d1aa573167 fix(budget): seed edit fields with two decimals and allow signed amounts for refunds (#2175, #2176) 2026-09-01 17:18:10 +02:00
Maurice 49b0ce28d5 fix(planner): no hotel bookend legs against a same-day carrier without stay times (#2157) 2026-09-01 17:18:10 +02:00
Maurice 2e0c496a93 fix(planner): edit details handoff from the transit journey dialog (#2148) 2026-09-01 17:18:10 +02:00
Maurice 2e00c998c0 fix(calendar): give stay check-in/check-out markers the one-hour handover window (#2136) 2026-09-01 17:18:10 +02:00
Maurice b9e8821114 fix(dashboard): no press-scale on composite trip cards (#2158) 2026-09-01 17:18:10 +02:00
Maurice a96bebc8bb feat(journey): photo provider buttons beside upload in the gallery header 2026-09-01 17:18:10 +02:00
Maurice 9f6cd3dc6d fix(collab): sanitize cross-user note markdown and harden note links 2026-09-01 17:18:10 +02:00
Maurice 37d7365707 feat(collab): markdown poll questions, multiline inputs and wrapping options (#2177) 2026-09-01 17:18:10 +02:00
Maurice 354a89f25b fix(todo): stop the press-scale animation from swallowing checkbox clicks (#2158) 2026-09-01 17:18:10 +02:00
Maurice 0a3ba7a4d7 fix(planner): keep a restored route toggle scoped to the selected day (#2019) 2026-09-01 17:18:10 +02:00
Maurice c59425947a fix(server): unicode-safe content-disposition for gpx, ics, feed and wallet downloads (#2165) 2026-09-01 17:18:10 +02:00
Maurice 4704bf5903 fix(auth): fail fast on passkey origins outside the relying party scope (#2147) 2026-09-01 17:18:10 +02:00
Maurice d5a3427cbb fix(packing): accept weight, bag and quantity on create across REST, MCP and plugin RPC (#2154) 2026-09-01 17:18:10 +02:00
Maurice a0c089baa4 test(memories): immich suites need the journey addon on now 2026-09-01 16:19:21 +02:00
Maurice fef5c39581 feat(admin): photo providers require the journey addon 2026-09-01 16:19:21 +02:00
Maurice 2834967919 fix(i18n): drop the self-hosted wording from the airtrail description 2026-09-01 16:19:21 +02:00
Maurice 3472d06e62 feat(admin): rebuild the addons page as a tile grid with sub-shelves
The three stacked sections become one grid of cards per group, with the
type shown on the tile itself. Bag tracking, the collab features and the
photo providers hang off their parent addon as compact shelf rows
instead of full-width siblings, and each group header counts how many of
its addons are on.

Along the way:
- naver_list_import, airtrail and llm_parsing get catalog entries, so
  their names and descriptions translate like everyone else
- dark detection moves into a shared useIsDark hook that watches the
  .dark class instead of re-deriving settings + matchMedia locally
- the ToggleSwitch knob uses --accent-text so a light accent in dark
  mode no longer renders white-on-white
2026-09-01 16:19:21 +02:00
Maurice 9bfdc19e68 fix(admin): naver_list_import is an integration, not a trip addon
It has no trip tab and no page of its own — it pulls places from an
external service, which is what the integration group is for. The type
only drives grouping in the admin UI (and the global nav check), so
existing installs just see the tile move; migration appended for them,
seed fixed for fresh ones.
2026-09-01 16:19:21 +02:00
RemEx 595a76321f fix(costs): show recorded settlement payments in the mobile Costs tab
Closes #2151

The desktop Costs view mixes recorded settle-up payments into the day-grouped
expense ledger, but the mobile screen only ever fetched balances/flows and
silently dropped the settlements array the server already returns — a
recorded payment cleared the balance but then left no trace anywhere on
mobile.
2026-09-01 14:39:37 +02:00
Cartoone9 537c450653 fix: boot in the account's language when offline
The store boots from localStorage, but 'app_language' is only written
on an explicit in-app choice; a language that lives in the account's
server settings never reaches the device. A PWA cold start with no
network then runs in English even though every online session ran in
the user's language, the same stranding #1618 fixed for currency and
units.

loadSettings now mirrors the fetched language into its own key
('app_language_server'), and the boot chain falls back to it between
the explicit choice and 'en'. A separate key on purpose: 'app_language'
means an explicit choice, and the login page's detection chain must
keep running for users who never made one.
2026-09-01 14:20:10 +02:00
Eugene Korchagin 92bb834f2d fix(atlas): keep long location-note tooltips on-screen and scrollable
Closes #2153

Bucket-list marker tooltips rendered as a single nowrap line, so long
notes overflowed off both edges of the viewport instead of wrapping.

Wrap and cap the tooltip's size, add a scrollbar for notes that still
exceed it (only shown when content actually overflows, and scoped to
this tooltip so short region/country tooltips are unaffected), and
make the tooltip interactive with a delayed close so the pointer can
reach it to scroll instead of Leaflet closing it the instant it leaves
the marker's tiny hit area. Also compute the tooltip's direction and
offset from the marker's on-screen position before it opens, so it
flips below the marker and stays clear of the left/right edges when
there's no room to render it centered above.

The positioning/scroll math is pure and covered by unit tests in
atlasModel.test.ts. Updated the L.marker mocks in AtlasPage.test.tsx
and useAtlas.test.tsx with on/off/getTooltip/closeTooltip so the new
marker event wiring doesn't crash under test.
2026-09-01 14:20:06 +02:00
jubnl 394201cd32 feat(plugins): instance-wide settings editor in the admin UI (#2170)
* chore: bump version to 4.1.0 [skip ci]

* fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone

Three places answered "is this a place the trip already has?", not two:
PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy
list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and
kept its own hand-rolled copy of the pre-#2130 order.

Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of
it — savePlace calls it directly — so the coordinate fallback for a NAMED
candidate was live, not latent: two distinct places sharing an address (a
restaurant and the bar upstairs) would report each other as duplicates the
moment their coordinates round to the same ~11 m box, regardless of name.

It also never read google_place_id/google_ftid/osm_id at all, even though
every collection_places row stores them, so a place renamed since it was
saved could be saved again instead of being recognised by its provider id.

Now walks placeMatchStrategies() from @trek/shared, same as
findMatchingPlaceId: provider id, then name, then coordinates only when there
is no name. savePlace passes the provider id fields through since they are
already on the request body; the two bulk call sites (importablePlaces,
saveFromTripPlaces) are unchanged, since their source rows don't carry
provider ids to pass.

Closes #2137

* fix(collections): ask the same question at all three call sites

savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy
already carries google_place_id/google_ftid/osm_id into the row it writes but
asked without them, and the import picker never selected them at all, so a
renamed place was recognised on one path and not on the other two. The picker
answering differently from the import is the drift the method exists to prevent:
a row shown as new would come back refused.

COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker.

* fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down

Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom
specified", and so could the map settings tab, which left no screen from which to
pick a different basemap.

Leaflet answers getMaxZoom() from the map options first and otherwise from a
layer that brought one, and only a GridLayer ever contributes via its beforeAdd
hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes
nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd
refuses an infinite one by throwing, before any clustering, so an empty trip hit
it too.

That path opened when the default basemap became a MapLibre style. It reaches
further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless
CARTO template to the app default, which is now a vector style, so every
instance still carrying the old CARTO basemap fell into it on upgrade.

The ceiling belongs on the map rather than on the base layer, because it has to
hold whichever of the three branches renders. 19 matches what the raster and
satellite layers already carried, so nothing changes for the maps that worked.
SharedTripPage gets the same treatment: it has no cluster to throw, but it draws
the same vector basemap and its fitBounds asks for a ceiling.

mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library
rather than a mock, since a mocked react-leaflet would have kept passing through
all of this.

* fix(map): put the CARTO key back on the template the settings preview draws

The map settings fields hold what the user is editing rather than what
useTileUrl already resolved, so the preview received a bare template with no key
on it. resolveTileUrl then read it as a keyless CARTO url and did what it is
meant to do with one: fall back to the app default. The preview drew
OpenFreeMap while the fields said CARTO, whether or not a key was saved.

Same reason the tab crashed for anyone on a CARTO basemap even after entering a
key, which is the loop that made this hard to escape: the key is entered on the
page the fallback had taken down.

FE-COMP-MAP-033b pins the template the preview is handed, with and without a key.

* fix(admin): put the CARTO key back on the default-settings map preview too

Same shape as the user-facing map tab, and reported alongside it: the field holds
what the admin is editing, so the preview resolved a keyless CARTO template and
drew the app default instead of the basemap being set.

* fix(mobile): put the CARTO key back on the phone map settings preview too

The third preview with the same shape, and the one that matters most while the
crash is live: the phone layout is where someone would go to change the basemap
when the desktop tab will not open.

* fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar

Closes #2136

A stay was always exported as an all-day block across its whole range, and the
check-in and check-out markers came on top of it. When the stay records both
ends of its clock the markers already say everything the block does, and the
block is the half nobody can act on: it takes a week of calendar to repeat what
two one-hour events state precisely. Same shape as the parking and rental
windows in #2068.

Only where the markers actually stand in for the booking, though. They are
emitted once per stay and titled from its lowest-id reservation, so a second
room on the same stay would otherwise be left with nothing at all. A stay that
knows only one end keeps its block too, since nothing else carries the other
end's date.

CAL-025b covers the drop, -025c the one-sided stay, -025d the second room.

* fix(mobile): the phone admin map preview needs the CARTO key too

The fourth preview with the same shape, missed when the other three were fixed.
Sonar pointed at it: the desktop and phone admin pages are near-identical, so the
duplication report named the file the change had not reached.

* chore: bump version to 4.1.1 [skip ci]

* feat(plugins): admin editor for instance-wide plugin settings

The scope:'instance' settings a plugin declares had API endpoints but no UI.

- shared: plugins.schema.ts — the settings-field descriptor and the admin
  instance-config GET/PUT wire contracts, imported by both sides
- server: GET /api/admin/plugins/:id/config now returns the declared fields
  alongside the (masked) values; PUT re-spawns an ACTIVE plugin (its child
  reads config once, at init) and reports it via 'restarted'; the admin list
  carries instanceSettingsCount so the UI gates its menu item without a fetch
- client: 'Instance settings' row action opening a form modal (desktop) /
  MSheet (phone) in both admin shells — checkbox/select/secret rendering as
  in the user settings form; an untouched secret mask is never sent back
- i18n: three new admin.plugins.* keys in every locale

* refactor(plugins): move the admin body contracts to @trek/shared

The remaining plugin DTO schemas (install, link, activate, uninstall,
retrust, update, egress-hosts, user-settings update, route) now live in
shared/src/plugins/plugins.schema.ts with their inferred types, alongside
the instance-config contracts. The journey-contract doctrine notes (#1842)
moved with them — each body stays deliberately loose so the handlers keep
the rejections and fallbacks they already own, and new spec tests pin that
looseness so a well-meant tightening breaks a test that explains itself.

plugins.dto.ts is now thin createZodDto wrappers over the shared schemas
(same class names, no controller changes), and the client's pluginInstall
types its options and body against PluginInstallRequest.

* fix(admin): offer Allowed hosts only to plugins that declared operatorEgress

The row's egress chip was already gated on the declaration, but the kebab
menu (desktop) and row action sheet (phone) offered Allowed hosts on every
installed plugin — inviting the admin to widen egress for plugins that
never asked for it. Both entries now render only when the plugin declares
operatorEgress; the dialog's unsupported-notice fallback stays as the
truth-teller for stale list data.

* test(admin): close the Sonar gaps on the instance-settings form

The PR gate flagged new-code coverage (76.4%) and duplication (10.9%).

- The two identical 39-line handler blocks were logic, not markup — they
  move into one shared useInstanceSettings hook; each shell keeps its own
  markup (the deliberate desktop/phone divergence stays)
- New tests per shell cover the untested branches: every field type
  renders and round-trips into the save payload (select, checkbox/MToggle,
  number, hint, no-label key fallback), a failed fetch is a toast, a
  rejected save shows the server reason and keeps the edits, and the
  dialog/sheet closes without saving
- INS-009 covers the controller's no-body '|| {}' fallback

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: michael-bohr <263459669+michael-bohr@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-09-01 06:45:24 +02:00
jubnl 54b0e4eed4 Fix/sdk screenshot gate parity (#2164)
* fix(sdk): grade docs/screenshot.png exactly, like the registry gate

* docs(wiki): document plugin MCP tools and the exact screenshot gate

* test(sdk): cover the preflight screenshot probe
2026-08-31 14:32:53 +02:00
Konstantinos Thermos e615011e73 fix(journey): answer an entry edit with tags decoded, not the JSON string (#2155)
tags and pros_cons are JSON held in TEXT columns. Three read paths decoded
them by hand and createEntry and updateEntry did not, so an edit answered
with the raw row: tags came back as '["beach","sunset"]', the store spread
it into an entry the client types as string[], and the journey page threw
"tags.map is not a function" behind an error boundary.

Any edit was enough to arm it. The update path writes JSON.stringify(val)
with no length check, so even clearing an entry's tags stores '[]' and
answers with that string.

Neither compiler could see it: JourneyEntry describes the row, tags and
all, and both sides read the same field name while only one of them was
right. So the decoded shape gets a type of its own and one decoder, which
is what turned up the third case - the early return for an update that
changes nothing handed back a raw row too.

Fixes #2085
2026-08-31 14:32:00 +02:00
jubnl e70d7397dd fix(admin): let the plugin consent, signature and dependency dialogs scroll 2026-08-30 19:27:01 +02:00
Maurice f341efccc9 fix(mobile): the phone admin map preview needs the CARTO key too
The fourth preview with the same shape, missed when the other three were fixed.
Sonar pointed at it: the desktop and phone admin pages are near-identical, so the
duplication report named the file the change had not reached.
2026-08-29 16:40:39 +02:00
Maurice 2cac1ccd4f fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar
Closes #2136

A stay was always exported as an all-day block across its whole range, and the
check-in and check-out markers came on top of it. When the stay records both
ends of its clock the markers already say everything the block does, and the
block is the half nobody can act on: it takes a week of calendar to repeat what
two one-hour events state precisely. Same shape as the parking and rental
windows in #2068.

Only where the markers actually stand in for the booking, though. They are
emitted once per stay and titled from its lowest-id reservation, so a second
room on the same stay would otherwise be left with nothing at all. A stay that
knows only one end keeps its block too, since nothing else carries the other
end's date.

CAL-025b covers the drop, -025c the one-sided stay, -025d the second room.
2026-08-29 16:14:17 +02:00
Maurice 9d33bd71da fix(mobile): put the CARTO key back on the phone map settings preview too
The third preview with the same shape, and the one that matters most while the
crash is live: the phone layout is where someone would go to change the basemap
when the desktop tab will not open.
2026-08-29 16:13:31 +02:00
Maurice a4f8fabde0 fix(admin): put the CARTO key back on the default-settings map preview too
Same shape as the user-facing map tab, and reported alongside it: the field holds
what the admin is editing, so the preview resolved a keyless CARTO template and
drew the app default instead of the basemap being set.
2026-08-29 16:13:31 +02:00
Maurice 1984e151c1 fix(map): put the CARTO key back on the template the settings preview draws
The map settings fields hold what the user is editing rather than what
useTileUrl already resolved, so the preview received a bare template with no key
on it. resolveTileUrl then read it as a keyless CARTO url and did what it is
meant to do with one: fall back to the app default. The preview drew
OpenFreeMap while the fields said CARTO, whether or not a key was saved.

Same reason the tab crashed for anyone on a CARTO basemap even after entering a
key, which is the loop that made this hard to escape: the key is entered on the
page the fallback had taken down.

FE-COMP-MAP-033b pins the template the preview is handed, with and without a key.
2026-08-29 16:13:31 +02:00
Maurice dce348b947 fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down
Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom
specified", and so could the map settings tab, which left no screen from which to
pick a different basemap.

Leaflet answers getMaxZoom() from the map options first and otherwise from a
layer that brought one, and only a GridLayer ever contributes via its beforeAdd
hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes
nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd
refuses an infinite one by throwing, before any clustering, so an empty trip hit
it too.

That path opened when the default basemap became a MapLibre style. It reaches
further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless
CARTO template to the app default, which is now a vector style, so every
instance still carrying the old CARTO basemap fell into it on upgrade.

The ceiling belongs on the map rather than on the base layer, because it has to
hold whichever of the three branches renders. 19 matches what the raster and
satellite layers already carried, so nothing changes for the maps that worked.
SharedTripPage gets the same treatment: it has no cluster to throw, but it draws
the same vector basemap and its fitBounds asks for a ceiling.

mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library
rather than a mock, since a mocked react-leaflet would have kept passing through
all of this.
2026-08-29 16:13:31 +02:00
Maurice 78548ca2ba fix(collections): ask the same question at all three call sites
savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy
already carries google_place_id/google_ftid/osm_id into the row it writes but
asked without them, and the import picker never selected them at all, so a
renamed place was recognised on one path and not on the other two. The picker
answering differently from the import is the drift the method exists to prevent:
a row shown as new would come back refused.

COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker.
2026-08-29 16:04:36 +02:00
michael-bohr 8a0932df72 fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone
Three places answered "is this a place the trip already has?", not two:
PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy
list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and
kept its own hand-rolled copy of the pre-#2130 order.

Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of
it — savePlace calls it directly — so the coordinate fallback for a NAMED
candidate was live, not latent: two distinct places sharing an address (a
restaurant and the bar upstairs) would report each other as duplicates the
moment their coordinates round to the same ~11 m box, regardless of name.

It also never read google_place_id/google_ftid/osm_id at all, even though
every collection_places row stores them, so a place renamed since it was
saved could be saved again instead of being recognised by its provider id.

Now walks placeMatchStrategies() from @trek/shared, same as
findMatchingPlaceId: provider id, then name, then coordinates only when there
is no name. savePlace passes the provider id fields through since they are
already on the request body; the two bulk call sites (importablePlaces,
saveFromTripPlaces) are unchanged, since their source rows don't carry
provider ids to pass.

Closes #2137
2026-08-29 16:04:36 +02:00
963 changed files with 57059 additions and 4497 deletions
+66 -51
View File
@@ -1,18 +1,17 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc.
## What TREK is
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo:
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime.
- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. Must be built before server/client typecheck or run.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
- **`plugin-sdk/`** (`trek-plugin-sdk`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`.
**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture.
Each package has its own `CLAUDE.md`; this file covers the monorepo picture.
## Commands
@@ -21,7 +20,7 @@ Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not
```bash
npm run dev # build shared, then watch shared + server + client concurrently
npm run build # build shared → server → client (order matters)
npm run lint # lint all three workspaces
npm run lint # lint all three workspaces (server and shared run eslint --fix and rewrite files; client is check-only)
npm run format # prettier --write all three
```
@@ -29,17 +28,14 @@ Tests:
```bash
npm run test # all workspaces
npm run test --workspace=server # vitest run (server)
npm run test:unit --workspace=server # tests/unit only
npm run test:integration --workspace=server
npm run test:ws --workspace=server # websocket tests
npm run test:e2e # server e2e (boots Nest against temp SQLite)
npm run test:cov # coverage (lcov), all four packages incl. shared + plugin-sdk
npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
cd client && npm run test # client vitest run
cd client && npm run e2e # Playwright
cd client && npm run lint:pages # enforce the Page pattern (see below)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared)
cd client && npm run e2e # Playwright (local only)
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
```
Run a single test file or test:
@@ -49,65 +45,84 @@ npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or c
npx vitest run -t "name of the test" # by test-name pattern
```
i18n parity (enforced in CI — every non-`en` locale must have identical file set + top-level keys):
i18n parity (CI gate — every non-`en` locale must have the identical file set and top-level keys):
```bash
npm run i18n:parity --workspace=shared # audit (exit 0)
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
```
Dev ports: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`).
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). There is no Node version pin — the CI workflows and the Dockerfile define what is tested. `server/.env.example` is the env-var reference.
## Server architecture
## Server invariants
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest/<domain>/`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`).
Nest owns everything: every domain is a DI module under `server/src/nest/<domain>/` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src/<domain>/`.
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter.
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub.
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service.
- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate).
## Client architecture
## Client invariants
Offline-first, with a layered data flow. A Page never owns state directly:
- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons.
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment.
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
## Shared contracts & i18n
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
## Project direction
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
These principles come out of a full-repo audit and shape all new code:
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`.
- **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
## Conventions (from CONTRIBUTING.md)
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages.
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.**
- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage.
- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes.
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
| Condition (new code) | Must be |
|---|---|
| Coverage | ≥ 80% |
| Duplicated lines | ≤ 3% |
| Security / reliability / maintainability rating | A |
| Security hotspots reviewed | 100% |
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. Locale files are excluded from duplication, so i18n additions are free.
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
## Reference docs
- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e).
- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide).
- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack.
+2 -1
View File
@@ -8,7 +8,7 @@ This is a minimal Helm chart for deploying the TREK app.
- Optional persistent storage for `/app/data` and `/app/uploads`
- Configurable environment variables and secrets
- Optional generic Ingress support
- Health checks on `/api/health`
- Configurable health checks (liveness/readiness probes) on `/api/health`
## Helm Repository
@@ -46,6 +46,7 @@ See `values.yaml` for more options.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.1.1
version: 4.2.0
description: Minimal Helm chart for TREK app
appVersion: "4.1.1"
appVersion: "4.2.0"
+6 -10
View File
@@ -80,18 +80,14 @@ spec:
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
{{- with (default dict .Values.probes).liveness }}
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with (default dict .Values.probes).readiness }}
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: data
{{- if .Values.persistence.enabled }}
+19
View File
@@ -134,6 +134,25 @@ persistence:
existingClaim: ""
annotations: {}
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
# httpGet handler. Switching handler means clearing the old one in the same override
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
probes:
liveness:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
readiness:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 100m
+45 -53
View File
@@ -1,77 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only.
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory.
## Commands (run from `client/`)
```bash
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build
npm run typecheck # tsc --noEmit
npm run lint # eslint .
npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs)
npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx})
npm run test:unit # tests/unit
npm run test:integration # tests/integration + co-located src tests
npm run test:coverage # v8 coverage
npm run e2e # Playwright
npm run e2e:report # open last Playwright report
npm run shots # Playwright screenshots project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate)
npm run typecheck # tsc --noEmit (CI)
npm run lint # eslint . (lint:check in CI)
npm run lint:pages # enforce the Page pattern (CI gate)
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
npm run e2e # Playwright (local only; e2e:report opens the last report)
npm run shots # Playwright screenshot project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only)
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (local only)
```
Single test:
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
```bash
npx vitest run src/store/slices/budgetSlice.test.ts
npx vitest run -t "optimistically adds the place"
npx vitest watch src/pages/DashboardPage.test.tsx
```
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both.
## Page pattern (enforced — `lint:pages` fails otherwise)
## Page pattern (enforced — `lint:pages` will fail otherwise)
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives.
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
## Data flow — offline-first
The layering is **store → repo → api | Dexie**, and writes go through a mutation queue:
The layering is **component → feature hook → store/slice → `repo/` → `api/` | Dexie**, and writes go through a mutation queue:
- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly.
- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection.
- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access.
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles.
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
## Direction (from the 2026 audit)
## Rules for new code
The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts:
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/<domain>.ts`, not the `client.ts` god module.
- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly.
- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use<Page>()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`).
- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug.
- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces.
- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter.
- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `<html>` styling.
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand.
## Other big-picture pieces
## Big-picture pieces
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/<locale>')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`.
- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens/<domain>/` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated.
- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill.
- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build.
- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / `<ServerImage>`; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`.
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here.
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below.
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
## Desktop ↔ mobile shells and the SonarQube duplication gate
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. Full gate rules in the root `CLAUDE.md`.
## Tests
vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`).
vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`).
+10 -1
View File
@@ -41,6 +41,12 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
// An iPad is inside the 768-1023px band, where only one side panel is open at a
// time so the map keeps a usable width (#2247). The places list is one tap away.
// Located by the tab's own aria-label, not by role+name: the tab bar carries a
// "Plan" button of its own.
await page.locator('button[aria-label="Places"]').click()
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
// The context must really be the one from the bug report: coarse pointer, desktop
@@ -74,6 +80,9 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
})
expect(['1', 'absent']).toContain(arrowOpacity)
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
// 4. The iPad must still get the desktop shell — isMobile stayed width-based. One
// panel at a time (#2247), so opening Places closed the day plan and left its
// tab as the way back.
await expect(page.locator('.leaflet-container')).toBeVisible()
await expect(page.locator('button[aria-label="Plan"]')).toBeVisible()
})
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.1.1",
"version": "4.2.0",
"private": true,
"type": "module",
"scripts": {
@@ -37,6 +37,7 @@
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.2.7",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
+25
View File
@@ -0,0 +1,25 @@
import { createRequire } from 'node:module';
import path from 'node:path';
/**
* setRTLTextPlugin() takes the URL of a classic script that the GL worker pulls in
* with importScripts. mapbox-gl-rtl-text ships exactly that as
* dist/mapbox-gl-rtl-text.js, but its "exports" field publishes only the ESM/WASM
* entry, so the resolver refuses the subpath. Resolving the entry it does publish
* and stepping across to dist keeps this working wherever npm hoists the package.
*
* Shared by vite.config.js and vitest.config.ts: the build needs it to emit the
* asset, and the test run needs it because a lazily imported engine module reaches
* the same specifier.
*
* The pattern is a regex, not a string key: the import carries a `?url` suffix,
* which a string alias will not match. It is left unanchored on purpose so the
* query survives the rewrite and Vite still emits an asset URL.
*/
export const rtlTextAlias = {
find: /^@mapbox\/mapbox-gl-rtl-text\/dist\/mapbox-gl-rtl-text\.js/,
replacement: path.join(
path.dirname(createRequire(import.meta.url).resolve('@mapbox/mapbox-gl-rtl-text')),
'../dist/mapbox-gl-rtl-text.js',
),
};
+21
View File
@@ -26,4 +26,25 @@ for (const { name, size } of sizes) {
console.log(` \u2713 ${name} (${size}x${size})`);
}
// Maskable variants: Android lays a maskable icon edge to edge under the round
// launcher mask, so the glyph has to sit inside the safe zone (a circle of 80%
// of the canvas). The master SVG draws the glyph nearly full bleed; shrink and
// recenter it, keeping the brand gradient as the full background.
const maskableSvg = svgBuffer
.toString()
.replace('translate(56,51) scale(0.267)', 'translate(81,81) scale(0.234)');
const maskableSizes = [
{ name: 'icon-maskable-192x192.png', size: 192 },
{ name: 'icon-maskable-512x512.png', size: 512 },
];
for (const { name, size } of maskableSizes) {
await sharp(Buffer.from(maskableSvg), { density: 300 })
.resize(size, size)
.png({ compressionLevel: 9 })
.toFile(join(iconsDir, name));
console.log(` \u2713 ${name} (${size}x${size}, maskable)`);
}
console.log('PWA icons generated.');
+77
View File
@@ -522,6 +522,83 @@ describe('Version cache-busting', () => {
renderApp('/')
await waitFor(() => expect(reload).toHaveBeenCalled())
})
it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => {
// The old handler wiped every Cache Storage bucket and every registration
// here, which left the device with no app shell until a fresh ~22 MB
// precache finished: anyone who closed the app or lost signal inside that
// window could no longer start the PWA offline at all, and it also threw
// away the map tiles and documents the user had downloaded on purpose.
localStorage.setItem('trek_app_version', '2.9.9')
const cachesDelete = vi.fn().mockResolvedValue(true)
const unregister = vi.fn().mockResolvedValue(true)
const update = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
getRegistration: vi.fn().mockResolvedValue({ update, unregister }),
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
addEventListener: vi.fn(),
},
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
// It asks the worker to fetch the new build; Workbox swaps the precache
// atomically once that install completes.
await waitFor(() => expect(update).toHaveBeenCalled())
expect(cachesDelete).not.toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
})
it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => {
// The value arrives over the wire and is written to this device's storage,
// so it is validated rather than trusted. An unusable one must also not be
// compared against the stored marker, or every launch would see a mismatch
// and try to update.
localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '<script>alert(1)</script>', managed: false })
)
)
seedAuth()
renderApp('/')
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => {
// The marker used to be written after the purge and outside its try, so a
// throwing step left it unwritten and the whole destructive path repeated on
// every launch, for good.
localStorage.setItem('trek_app_version', '2.9.9')
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
})
})
// Regression: a device that has never mirrored the preference must not treat
+50 -18
View File
@@ -19,7 +19,7 @@ import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
import { authApi, isAuthPublicPath } from './api/client'
import { tripRepo } from './repo/tripRepo'
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
@@ -283,7 +283,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -300,7 +300,7 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -314,26 +314,49 @@ export default function App() {
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) {
// A version is a short release tag and nothing else. It arrives over the
// wire and is written to this device's storage, so it is rebuilt from the
// characters a tag may contain and kept only when nothing had to be
// stripped. Anything else is ignored, which also keeps a malformed value
// from being compared against the stored marker and starting an update on
// every launch.
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
if (version && version === reportedVersion) {
const storedVersion = localStorage.getItem('trek_app_version')
if (storedVersion && storedVersion !== config.version) {
// Record the version BEFORE acting on it. The old code wrote the marker
// after the purge and outside its try, so a throwing setItem (private
// mode, blocked site data, quota) left the caches deleted, the marker
// unwritten and the reload unreached, and the purge then repeated on
// every single launch, permanently (#2228).
try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ }
if (storedVersion && storedVersion !== version) {
// A newer build is deployed. Ask the service worker to fetch it and
// hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim)
// installs the new precache and only then drops the outdated one, so
// there is never a moment without an app shell.
//
// This used to delete EVERY Cache Storage bucket and unregister EVERY
// worker instead. That left the device with no shell and no worker
// until a fresh ~22 MB precache finished (minutes on mobile data),
// and anyone who closed the app or lost signal in that window was
// left with a PWA that could no longer start offline at all. It also
// threw away the map tiles and file blobs the user had deliberately
// downloaded for offline use (#2228).
try {
if ('caches' in window) {
const names = await caches.keys()
await Promise.all(names.map(n => caches.delete(n)))
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
if (reg) {
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true })
await reg.update()
return
}
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map(r => r.unregister()))
}
} catch {}
localStorage.setItem('trek_app_version', config.version)
} catch { /* fall through to a plain reload */ }
window.location.reload()
return
}
localStorage.setItem('trek_app_version', config.version)
}
}).catch(() => {})
}, [])
@@ -380,13 +403,22 @@ export default function App() {
|| location.pathname.startsWith('/register')
|| location.pathname.startsWith('/forgot-password')
|| location.pathname.startsWith('/reset-password')
// No session on these, so authenticated-only widgets (system notices,
// background tasks, save-to-collection) have nothing to do and would only fire
// a doomed authenticated request on mount.
//
// Off isAuthPublicPath rather than a second hand-kept list: the two had already
// drifted, this one naming only /public/journey/ while the response
// interceptor's covers all of /public/. A route that is public to one and not
// to the other is exactly the seam that puts a 401 back.
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
return (
<TranslationProvider>
{!isAuthPage && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
{!isAuthPage && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
+21 -1
View File
@@ -230,7 +230,8 @@ describe('client > proxy auth challenges', () => {
return getRegistration
}
it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => {
it('FE-APIWIRE-011: an HTML 401 behind a confirmed proxy wall unregisters the service worker and reloads', async () => {
probeNow.mockResolvedValue('proxy-wall')
const unregister = vi.fn(async () => true)
installServiceWorker(unregister)
server.use(http.get('/api/auth/me', () =>
@@ -243,6 +244,25 @@ describe('client > proxy auth challenges', () => {
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1')
})
it('FE-APIWIRE-016: an HTML 401 from TREK itself keeps the service worker (#2228)', async () => {
// text/html is not proof of a proxy: several of TREK's own routes answer
// res.status(401).send('Authentication required'), which Express labels
// text/html. Tearing the worker down for one of those costs the user
// offline mode for a wall that is not there, so confirm reachability first.
probeNow.mockResolvedValue('online')
const unregister = vi.fn(async () => true)
installServiceWorker(unregister)
server.use(http.get('/api/auth/me', () =>
new HttpResponse('Authentication required', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(probeNow).toHaveBeenCalled()
expect(unregister).not.toHaveBeenCalled()
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => {
installServiceWorker(vi.fn(async () => true))
sessionStorage.setItem('proxy_reauth_attempted', '1')
+20
View File
@@ -301,6 +301,9 @@ describe('client > endpoint wiring', () => {
{ n: 'pluginRescan', r: () => adminApi.pluginRescan(), e: 'POST /api/admin/plugins/rescan' },
{ n: 'pluginLink', r: () => adminApi.pluginLink('/srv/plugin'), e: 'POST /api/admin/plugins/link' },
{ n: 'pluginReload', r: () => adminApi.pluginReload('koffi'), e: 'POST /api/admin/plugins/koffi/reload' },
{ n: 'pluginConfig', r: () => adminApi.pluginConfig('koffi'), e: 'GET /api/admin/plugins/koffi/config' },
{ n: 'pluginSaveConfig', r: () => adminApi.pluginSaveConfig('koffi', { apiUrl: 'x' }), e: 'PUT /api/admin/plugins/koffi/config' },
{ n: 'runPluginAction', r: () => adminApi.runPluginAction('koffi', 'purge cache'), e: 'POST /api/admin/plugins/koffi/actions/purge%20cache' },
{ n: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' },
{ n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' },
{ n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' },
@@ -862,4 +865,21 @@ describe('client > multipart uploads', () => {
expect(post.mock.calls[1][0]).toBe('/backup/upload-restore')
expect(((post.mock.calls[1][1] as FormData).get('backup') as File).name).toBe('backup.zip')
})
it('FE-APISURF-053: every channel test outlives the 8s global timeout', async () => {
const post = spyPost()
// The server budgets up to 20s for an SMTP dial and 10s for a webhook or
// ntfy ping; aborting at 8s threw away the reason and left the admin with a
// bare "failed" (#2196).
await notificationsApi.testSmtp('a@b.c')
await notificationsApi.testWebhook('https://hook')
await notificationsApi.testNtfy({ topic: 't' })
await notificationsApi.testChannel('plugin/ch')
expect(post.mock.calls).toHaveLength(4)
for (const call of post.mock.calls) {
expect(call[2]).toMatchObject({ timeout: 40000 })
}
})
})
+261 -31
View File
@@ -1,6 +1,8 @@
import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Place } from '../types'
import type { TransitProvider } from '@trek/shared'
import { randomId } from '../utils/randomId'
import {
weatherResultSchema, type WeatherResult,
@@ -30,7 +32,7 @@ import {
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type PlaceBulkDeleteRequest,
type PlaceBulkUpdateRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
@@ -53,6 +55,18 @@ import {
type StorageConfigPut,
type StorageTestResponse,
type StorageUsage,
type PluginSettingsField,
type PluginInstanceConfigResponse,
type PluginInstanceConfigUpdated,
type PluginActionDescriptor,
type PluginActionResult,
type PluginInstallRequest,
RoadtripDayTrack,
RoadtripVia,
RoadtripViaBatchRequest,
RoadtripViaCreateRequest,
RoadtripViaReanchorRequest,
RoadtripViaUpdateRequest,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity'
@@ -122,7 +136,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
function translateRateLimit(): string {
const fallback = RATE_LIMIT_MESSAGES['en']!
try {
const lang = localStorage.getItem('app_language') || 'en'
const lang = localStorage.getItem('app_language') || localStorage.getItem('app_language_server') || 'en'
return RATE_LIMIT_MESSAGES[lang] ?? fallback
} catch {
return fallback
@@ -208,16 +222,24 @@ apiClient.interceptors.response.use(
}
}
// Pangolin header-auth extended compatibility mode: returns 401 with an
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
// always application/json, so checking for text/html is unambiguous.
// HTML body (a JS redirect page) instead of a 302.
//
// A text/html 401 is NOT unambiguous on its own: several of TREK's own
// routes answer with res.status(401).send('Authentication required'),
// which Express labels text/html. Tearing the service worker down for one
// of those would cost the user offline mode for a proxy wall that isn't
// there, so confirm a reachable proxy first, exactly like the no-response
// branch above (#2228).
if (error.response?.status === 401) {
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
if (ct.includes('text/html')) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
if (await probeNow() === 'proxy-wall') {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
}
}
}
}
@@ -418,7 +440,10 @@ export const daysApi = {
export const placesApi = {
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
// Typed: an untyped `r.data` is what let `{ place }` be read as a bare place,
// so every hotel booking minted an unlinked duplicate (#2243).
create: (tripId: number | string, data: PlaceCreateRequest): Promise<{ place: Place }> =>
apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
@@ -447,8 +472,15 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
},
// A longer timeout than the shared 8 s, like the other routes here that wait
// on somebody else's service. A directions link whose stops are only named
// has to be geocoded one at a time behind a 1.1 s throttle, so a route with
// eight stops needs about ten seconds. Giving up at eight left the server
// finishing the import and writing the places while the browser reported a
// failure, and a retry then spent the whole geocoding budget again only to
// have the dedupe skip every stop.
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest, { timeout: 60000 }).then(r => r.data),
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
@@ -467,6 +499,8 @@ export const assignmentsApi = {
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
// Day-specific note on an assignment (#2163) — null clears it.
updateNotes: (tripId: number | string, id: number, data: AssignmentNotesRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/notes`, data).then(r => r.data),
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
// direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case
// and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape.
@@ -535,8 +569,8 @@ export const adminApi = {
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
// `version` pins the exact version to install (the rollback path); omitted, the server
@@ -556,6 +590,17 @@ export const adminApi = {
// Dev-link (dev-only): register a plugin from a local built dir + hot-reload it.
pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data),
pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data),
// The admin-owned `scope:'instance'` settings: the declared fields plus the stored
// values (secrets masked). Saving may RESTART a running plugin — the child gets its
// config once at init — and `restarted` reports whether that happened.
pluginConfig: (id: string): Promise<PluginInstanceConfigResponse> =>
apiClient.get(`/admin/plugins/${id}/config`).then(r => r.data),
pluginSaveConfig: (id: string, config: Record<string, unknown>): Promise<PluginInstanceConfigUpdated> =>
apiClient.put(`/admin/plugins/${id}/config`, config).then(r => r.data),
// Run a `scope:'instance'` action the plugin declared ("Purge cache"). Runs AS the
// clicking admin. 404 when the plugin is inactive (no child process to run it).
runPluginAction: (id: string, key: string): Promise<PluginActionResult> =>
apiClient.post(`/admin/plugins/${id}/actions/${encodeURIComponent(key)}`).then(r => r.data),
// Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name
// the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns
// the plugin with the widened allow-list.
@@ -616,12 +661,18 @@ export const adminApi = {
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getTrekPlaces: () => apiClient.get('/admin/trek-places').then(r => r.data),
updateTrekPlaces: (enabled: boolean) => apiClient.put('/admin/trek-places', { enabled }).then(r => r.data),
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
updateTransitProvider: (provider: TransitProvider) => apiClient.put('/admin/transit-provider', { provider }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -782,16 +833,13 @@ export interface PluginAtlasLayer {
countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }>
}
export interface PluginUserSettingField {
key: string; label?: string | null; input_type?: string; placeholder?: string | null;
hint?: string | null; required?: boolean; secret?: boolean;
options?: Array<{ value: string; label: string }>
}
/** The settings-field descriptor is the SHARED contract (both scopes emit the same
* shape) — aliased so existing per-user settings consumers keep their import path. */
export type PluginUserSettingField = PluginSettingsField
/** A button a plugin contributes to its own settings page ("Test connection"). */
export interface PluginAction {
key: string; label: string; hint?: string; danger: boolean
}
/** A button a plugin contributes to a settings form ("Test connection", "Purge cache").
* `scope` says which form: the user tab or the admin instance-settings dialog. */
export type PluginAction = PluginActionDescriptor
export const pluginsApi = {
// Active plugins the client renders (page nav entries, dashboard widgets).
@@ -862,7 +910,7 @@ export const pluginsApi = {
// caller, so it reads the caller's own settings.
runAction: (id: string, key: string) =>
apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`)
.then(r => r.data as { ok: boolean; message?: string }),
.then(r => r.data as PluginActionResult),
saveUserSettings: (id: string, config: Record<string, unknown>) =>
apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record<string, unknown> }),
// Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers).
@@ -1006,11 +1054,134 @@ export const memoriesApi = {
}).then(r => r.data),
}
/**
* Place search that still answers with no network.
*
* The cache is what `sync/placePrefetcher` stored for the trip's area, and this
* is the one place it is read from, so all ten call sites got the offline path
* without any of them learning about it. `source` says 'offline-cache' rather
* than pretending to be the index — several screens show it, and a stale answer
* that claims to be live is worse than one that says what it is.
*
* Only network-level failures fall through. A 4xx or 5xx means the server did
* answer and the caller has to see it.
*/
/**
* The same offline treatment as `withCachedPlaces`, for the single-place lookup.
*
* Separate because the shape is different: this one is keyed by id rather than
* by a query, and a miss has to stay a miss — a place the cache does not hold
* must fail the way it did before, not answer with somebody else's record.
*/
/**
* The rejection both search surfaces already know to swallow.
*
* They filter on the axios cancel code and on the DOMException name, so an
* abort from the cache path has to look like one or it surfaces as a toast.
*/
function abortedError(): Error & { code: string } {
const err = new Error('canceled') as Error & { code: string }
err.name = 'CanceledError'
err.code = 'ERR_CANCELED'
return err
}
async function withCachedPlace(
placeId: string,
online: () => Promise<{ place: Record<string, unknown> | null }>,
): Promise<{ place: Record<string, unknown> | null }> {
const fromCache = async (): Promise<{ place: Record<string, unknown> | null } | null> => {
const { getCachedPlace, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
const hit = await getCachedPlace(placeId)
return hit ? { place: cachedToPlaceRecord(hit) } : null
}
if (isEffectivelyOffline()) {
const cached = await fromCache()
if (cached) return cached
return online()
}
try {
return await online()
} catch (err) {
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
if (!neverArrived) throw err
const cached = await fromCache()
if (!cached) throw err
return cached
}
}
async function withCachedPlaces<T>(
query: string,
shape: (places: Record<string, unknown>[]) => T,
online: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const fromCache = async (): Promise<Record<string, unknown>[]> => {
const { searchCachedPlaces, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
const found = (await searchCachedPlaces(query)).map(cachedToPlaceRecord)
// The keystroke this answers may already be two keystrokes old. Both search
// surfaces order their suggestions by aborting the previous request and
// discarding the rejection — there is no request counter — so an offline
// answer that resolves regardless of the signal can overwrite a newer list.
// The two cache reads are also very unequal: a prefix hit comes off the
// index, the substring pass walks every cached place of every trip.
if (signal?.aborted) throw abortedError()
return found
}
if (isEffectivelyOffline()) return shape(await fromCache())
try {
return await online()
} catch (err) {
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
if (!neverArrived) throw err
const cached = await fromCache()
if (!cached.length) throw err
return shape(cached)
}
}
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
/**
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
* is the difference between finding the temple the user stands next to and
* one 400km away. The route has always accepted it; nothing passed it.
*
* It also decides whether the index answers at all: a common single word
* without coordinates is refused upstream as too expensive, and the search
* then falls back to Nominatim alone.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
withCachedPlaces(
input,
(places) => ({
suggestions: places.map((p) => ({
placeId: String(p.osm_id),
mainText: String(p.name),
secondaryText: String(p.address || ''),
})),
source: 'offline-cache',
}),
() => apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
signal,
),
// Answered from the cache when the network is not there, for the ids the
// offline suggestion list hands out. Without it, picking an offline
// suggestion failed here and the callers fell back to searching for
// "name, address" — which the cache matches on the folded NAME alone, so it
// found nothing and the user got an error toast for a place that was sitting
// in the cache all along.
details: (placeId: string, lang?: string, sessionToken?: string) =>
withCachedPlace(placeId, () =>
apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } })
.then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details'))),
// Pictures and a description for a place that is being looked at but not yet
// saved. Fans out to several providers server-side, so it takes a signal and
// the caller is expected to abort it when the selection changes, and a longer
@@ -1030,10 +1201,60 @@ export const mapsApi = {
// OSM-only POI explore: places of a category within the current map viewport bbox.
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
// timeout than the global default instead of aborting at 8s and showing nothing.
/**
* Every place in a box, for the offline cache. One call per trip area, not
* per keystroke, so the timeout is generous where the search ones are short.
*/
area: (
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number },
limit?: number,
signal?: AbortSignal,
) =>
apiClient
.get('/maps/area', { params: { ...bbox, limit }, signal, timeout: 30000 })
.then(
(r) =>
r.data as {
results: Record<string, unknown>[]
truncated: boolean
unavailable?: boolean
},
),
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
}
/**
* Road-trip via points (#1797): the places a day's drive is routed through without
* stopping. Separate from places on purpose — a via bends the route, a stop is somewhere
* you go.
*/
export const roadtripApi = {
/** Every via of the trip, so all days can be routed without a request per day. */
listVias: (tripId: number | string) =>
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }),
addVia: (tripId: number | string, dayId: number | string, body: RoadtripViaCreateRequest) =>
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { via: RoadtripVia }),
/**
* Lay a chain of vias on one day in one write. Anything that derives its anchors from a
* line produces dozens of them, and one request each would re-route the whole trip once
* per point at better than a second apart.
*/
addVias: (tripId: number | string, dayId: number | string, body: RoadtripViaBatchRequest) =>
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias/batch`, body).then(r => r.data as { vias: RoadtripVia[] }),
/**
* Re-pin a day's vias in one write, after its stops changed shape. One request, not one
* per via: the anchors are only correct as a set.
*/
reanchorVias: (tripId: number | string, dayId: number | string, body: RoadtripViaReanchorRequest) =>
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { vias: RoadtripVia[] }),
moveVia: (tripId: number | string, dayId: number | string, id: number, body: RoadtripViaUpdateRequest) =>
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`, body).then(r => r.data as { via: RoadtripVia }),
removeVia: (tripId: number | string, dayId: number | string, id: number) =>
apiClient.delete(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`).then(r => r.data),
}
export const airportsApi = {
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
@@ -1108,7 +1329,8 @@ export const healthApi = {
export const weatherApi = {
// `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614).
get: (lat: number, lng: number, date: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
// `lang` localizes the description; when omitted the server keeps its own default (#2167).
get: (lat: number, lng: number, date: string, lang?: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, lang, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
}
@@ -1202,11 +1424,13 @@ export const shareApi = {
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
}
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
// Public transit routing (#1065) — proxied through the server, which picks the
// backend (Transitous/MOTIS, or Google since #1699). `lang` reaches the Google
// backend as the languageCode, so station names come back in the user's script.
export const transitApi = {
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number; lang?: string }) =>
apiClient.get('/transit/plan', { params }).then(r => r.data),
}
@@ -1220,15 +1444,21 @@ export const tripInviteApi = {
accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data),
}
// A channel test dials a third party the admin just typed in, and the server
// budgets for it: up to 20s for a wrong SMTP port, 10s for a webhook or ntfy
// endpoint. The 8s instance timeout aborted those before the reason came back,
// so the toast could only ever say "failed" (#2196).
const CHANNEL_TEST_TIMEOUT = 40000
export const notificationsApi = {
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
// Generic channel test — this is how a PLUGIN channel's "Send test" button works.
testChannel: (channelId: string) =>
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`)
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`, undefined, { timeout: CHANNEL_TEST_TIMEOUT })
.then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')),
}
+8 -5
View File
@@ -59,23 +59,26 @@ describe('collectionsApi', () => {
expect(res.collection.name).toBe('Tokyo')
})
it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => {
server.use(http.post(BASE, record({ collection })))
// Both handlers answer with the BARE collection, the way the controller does —
// stubbing a { collection } envelope here is what let the client keep reading
// `.collection` off a response that never had one.
it('FE-API-COLLECTIONS-003: create() posts the create payload and returns the list itself', async () => {
server.use(http.post(BASE, record(collection)))
const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' })
expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' })
expect(res.collection.id).toBe(1)
expect(res.id).toBe(1)
})
it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => {
server.use(http.patch(`${BASE}/:id`, record({ collection })))
server.use(http.patch(`${BASE}/:id`, record(collection)))
const res = await collectionsApi.update(1, { name: 'Tokyo 2026' })
expect(requestUrl).toContain(`${BASE}/1`)
expect(requestBody).toEqual({ name: 'Tokyo 2026' })
expect(res.collection).toEqual(collection)
expect(res).toEqual(collection)
})
it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => {
+6 -2
View File
@@ -53,9 +53,13 @@ export const collectionsApi = {
ax.get(base).then((r: AxiosResponse) => r.data),
get: (id: number): Promise<CollectionDetailResponse> =>
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
// Both answer with the bare collection, not a { collection } envelope — the
// controller returns the service's `Collection` straight through, and the e2e
// suite pins that shape. Declaring the envelope made createCollection() resolve
// undefined for every caller.
create: (body: CollectionCreateRequest): Promise<Collection> =>
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
update: (id: number, body: CollectionUpdateRequest): Promise<Collection> =>
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
postMultipart(`${base}/${id}/cover`, formData),
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { useAuthStore } from '../store/authStore'
import { recordPlacePick } from './placeShadow'
import type { PlaceShadowPickRequest } from '@trek/shared'
/**
* The point of this module is that it never gets in the way: it must not fire
* when the instance has the log switched off, must not throw when the request
* fails, and must not send coordinates finer than the ranking needs.
*/
const PICK: PlaceShadowPickRequest = {
query: ' losteria rostock ',
lang: 'de',
biasLat: 54.088712,
biasLng: 12.140493,
source: 'search:nominatim',
liveRank: 0,
liveCount: 8,
pickedName: "L'Osteria",
pickedLat: 54.0891234,
pickedLng: 12.1372987,
pickedPlaceId: 'osm:node/1',
}
/** Resolves with the body the module posted, or null if it never posted. */
function capture(status = 200) {
let seen: unknown = null
let resolve: (v: unknown) => void = () => {}
const posted = new Promise<unknown>(r => { resolve = r })
server.use(
http.post('/api/place-shadow/pick', async ({ request }) => {
seen = await request.json()
resolve(seen)
return status === 200
? HttpResponse.json({ recorded: true })
: HttpResponse.json({ error: 'nope' }, { status })
}),
)
return { posted, seen: () => seen }
}
beforeEach(() => {
useAuthStore.setState({ placeShadowEnabled: true })
})
describe('recordPlacePick', () => {
it('sends nothing at all when the instance has the log switched off', async () => {
useAuthStore.setState({ placeShadowEnabled: false })
const cap = capture()
recordPlacePick(PICK)
await new Promise(r => setTimeout(r, 20))
expect(cap.seen()).toBeNull()
})
it('trims the query and rounds every coordinate to three decimals', async () => {
const cap = capture()
recordPlacePick(PICK)
expect(await cap.posted).toMatchObject({
query: 'losteria rostock',
pickedLat: 54.089,
pickedLng: 12.137,
biasLat: 54.089,
biasLng: 12.14,
})
})
it('leaves an absent bias absent rather than sending a zero', async () => {
const cap = capture()
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
const body = (await cap.posted) as Record<string, unknown>
expect(body.biasLat).toBeUndefined()
expect(body.biasLng).toBeUndefined()
})
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
const cap = capture()
recordPlacePick({ ...PICK, query: ' ' })
recordPlacePick({ ...PICK, pickedName: '' })
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
await new Promise(r => setTimeout(r, 20))
expect(cap.seen()).toBeNull()
})
it('does not record a pick made out of the offline cache', async () => {
// Such a row describes the local cache's ordering, not a provider's, and
// the two figures this corpus exists for — how often the live answer was
// first, and how often it was in the top five — are averaged over every
// row. Mixing these in moves exactly the number a candidate index is later
// judged against. Not merely an offline concern either: offline is a
// setting, so the network is usually reachable and the POST would land.
let posted = false
server.use(http.post('/api/place-shadow/pick', () => { posted = true; return HttpResponse.json({ recorded: true }) }))
recordPlacePick({ ...PICK, source: 'search:offline-cache' })
recordPlacePick({ ...PICK, source: 'autocomplete:offline-cache' })
await new Promise(r => setTimeout(r, 20))
expect(posted).toBe(false)
})
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
const cap = capture(500)
const onUnhandled = vi.fn()
process.on('unhandledRejection', onUnhandled)
expect(() => recordPlacePick(PICK)).not.toThrow()
await cap.posted
await new Promise(r => setTimeout(r, 20))
process.off('unhandledRejection', onUnhandled)
expect(onUnhandled).not.toHaveBeenCalled()
})
})
+51
View File
@@ -0,0 +1,51 @@
import type { PlaceShadowPickRequest } from '@trek/shared'
import { apiClient } from './client'
import { useAuthStore } from '../store/authStore'
/**
* Records which search result the user picked, for the local shadow corpus.
*
* Fire and forget in the strongest sense: it never throws, never blocks the
* pick it is describing, and does nothing at all unless the instance switched
* the log on. Adding a place must not get slower or more fragile because
* somebody is collecting a measurement.
*
* The store flag is checked first so a switched-off instance makes no request
* at all; the server checks it again, because a client is not a permission.
*/
/** About 100 m. Enough to reproduce a ranking, too coarse to place someone. */
function coarse(value: number): number {
return Math.round(value * 1000) / 1000
}
export function recordPlacePick(pick: PlaceShadowPickRequest): void {
if (!useAuthStore.getState().placeShadowEnabled) return
if (!Number.isFinite(pick.pickedLat) || !Number.isFinite(pick.pickedLng)) return
if (pick.liveRank >= pick.liveCount) return
// A pick out of the offline cache describes the cache's ordering, not a
// provider's, and the two numbers this corpus exists for — how often the live
// answer was first, and how often it was in the top five — are averaged over
// every row. Mixing local rows in would move exactly the figure a candidate
// index is later judged against.
//
// Not merely an offline concern: the offline switch is a setting, so the
// network is usually reachable and the POST would go through.
if (pick.source.endsWith('offline-cache')) return
const body: PlaceShadowPickRequest = {
...pick,
query: pick.query.trim().slice(0, 200),
pickedName: pick.pickedName.slice(0, 300),
pickedLat: coarse(pick.pickedLat),
pickedLng: coarse(pick.pickedLng),
biasLat: pick.biasLat === undefined ? undefined : coarse(pick.biasLat),
biasLng: pick.biasLng === undefined ? undefined : coarse(pick.biasLng),
}
// The contract requires both; an unnamed result is not worth a row anyway.
if (!body.query || !body.pickedName) return
void apiClient.post('/place-shadow/pick', body).catch(() => {
// Deliberately silent. The user was adding a place, not running a study.
})
}
+5
View File
@@ -30,6 +30,11 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// In-app notifications — hooks/useInAppNotificationListener
'notification:new',
'notification:updated',
// Bag weights (#2191) — content-free ping; components/Packing/usePackingListPanel
// and mobile/screens/trip/tabs/MPackingListTab re-read the bags on it. It carries
// no state to apply, which is the point: the totals are summed server-side across
// members whose items this client may not see.
'packing:bag-totals',
// Collections — pages/collections/useCollections ('collections:' prefix listener)
'collections:updated',
'collections:accepted',
+109 -39
View File
@@ -1,5 +1,5 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -44,20 +44,16 @@ function modelsRoute(names: string[], seen?: (string | null)[]) {
});
}
/** The pill toggle of a top-level addon row. */
/** Every switch is labelled with the thing it controls, top-level tile or shelf
* row alike, so one lookup covers both. */
function addonToggle(name: string): HTMLElement {
const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement;
return within(row).getByRole('button');
return screen.getByRole('button', { name });
}
/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */
function subToggle(label: string): HTMLElement {
const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement;
return within(row).getByRole('button');
}
const subToggle = addonToggle;
function isOn(toggle: HTMLElement): boolean {
return toggle.style.background === 'var(--text-primary)';
return toggle.getAttribute('aria-pressed') === 'true';
}
beforeAll(() => {
@@ -82,6 +78,7 @@ beforeEach(() => {
afterEach(() => {
vi.restoreAllMocks();
document.documentElement.classList.remove('dark');
});
describe('AddonManager', () => {
@@ -144,13 +141,10 @@ describe('AddonManager', () => {
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
// Get toggle button - use getAllByRole since there might be multiple buttons
const buttons = screen.getAllByRole('button');
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
expect(toggleBtn).toBeInTheDocument();
const toggleBtn = addonToggle('Todo List');
expect(isOn(toggleBtn)).toBe(false);
// Before click - disabled state (border-primary bg)
await user.click(toggleBtn!);
await user.click(toggleBtn);
// After click - success toast
await screen.findByText('Addon updated');
@@ -169,17 +163,14 @@ describe('AddonManager', () => {
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
const buttons = screen.getAllByRole('button');
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
await user.click(toggleBtn!);
await user.click(addonToggle('Todo List'));
// Error toast appears
await screen.findByText('Failed to update addon');
// The disabled text should be back after rollback
// The switch is back off after rollback
await waitFor(() => {
const disabledTexts = screen.getAllByText('Disabled');
expect(disabledTexts.length).toBeGreaterThan(0);
expect(isOn(addonToggle('Todo List'))).toBe(false);
});
});
@@ -195,13 +186,7 @@ describe('AddonManager', () => {
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
);
await screen.findByText('Bag Tracking');
const bagTrackingToggle = screen.getAllByRole('button').find(b =>
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
);
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
// There should be two toggle buttons: one for the addon, one for bag tracking
await user.click(allBtns[allBtns.length - 1]);
await user.click(subToggle('Bag Tracking'));
expect(mockToggle).toHaveBeenCalled();
});
@@ -251,9 +236,10 @@ describe('AddonManager', () => {
// Journey addon is rendered
expect(screen.getByText('Journey')).toBeInTheDocument();
// Toggle buttons: journey toggle + 2 provider toggles
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
expect(toggleBtns.length).toBe(3);
// Journey's own switch plus one per provider, each labelled with what it controls
expect(isOn(addonToggle('Journey'))).toBe(true);
expect(isOn(subToggle('Unsplash'))).toBe(true);
expect(isOn(subToggle('Pexels'))).toBe(false);
});
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
@@ -278,7 +264,8 @@ describe('AddonManager', () => {
});
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } });
// The wordmark reads the .dark class, the same source applyAppearance() writes.
document.documentElement.classList.add('dark');
render(<AddonManager />);
await screen.findByText('No addons available');
@@ -309,11 +296,11 @@ describe('AddonManager', () => {
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement;
const immichRow = screen.getByText('Immich').closest('li') as HTMLElement;
expect(immichRow.querySelector('svg')).toBeInTheDocument();
const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement;
const synologyRow = screen.getByText('Synology Photos').closest('li') as HTMLElement;
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement;
const unsplashRow = screen.getByText('Unsplash').closest('li') as HTMLElement;
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
expect(isOn(subToggle('Immich'))).toBe(true);
@@ -361,6 +348,76 @@ describe('AddonManager', () => {
await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true));
});
it('FE-ADMIN-ADDON-030: provider sub-rows stay hidden while Journey itself is off', async () => {
server.use(addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await screen.findByText('Journey');
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
expect(screen.queryByText('Synology Photos')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-031: switching Journey off and on again shows the cascaded providers as off', async () => {
const user = userEvent.setup();
let loads = 0;
let journeyOn = true;
let immichOn = true;
server.use(
http.get('/api/admin/addons', () => {
loads += 1;
return HttpResponse.json({
addons: [
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }),
],
});
}),
http.put('/api/admin/addons/journey', async ({ request }) => {
journeyOn = (await request.json() as { enabled: boolean }).enabled;
// Journey off takes its providers with it, see admin.service.ts.
if (!journeyOn) immichOn = false;
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Immich');
await user.click(addonToggle('Journey'));
// The toast closes the whole toggle, re-read included.
await screen.findByText('Addon updated');
expect(loads).toBe(2);
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
await user.click(addonToggle('Journey'));
await screen.findByText('Immich');
expect(isOn(subToggle('Immich'))).toBe(false);
});
it('FE-ADMIN-ADDON-032: a toggle the server does not cascade keeps the snapshot it has', async () => {
const user = userEvent.setup();
let loads = 0;
server.use(
http.get('/api/admin/addons', () => {
loads += 1;
return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] });
}),
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
await user.click(addonToggle('Todo List'));
await screen.findByText('Addon updated');
expect(loads).toBe(1);
expect(isOn(addonToggle('Todo List'))).toBe(true);
});
it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
const user = userEvent.setup();
const onToggleCollabFeature = vi.fn();
@@ -396,8 +453,21 @@ describe('AddonManager', () => {
render(<AddonManager />);
await screen.findByText('AI Parsing');
expect(screen.getByText('Extract bookings from files')).toBeInTheDocument();
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
// The catalog key now exists, so the tile shows the translation rather than falling
// back to the English description the server sent.
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
render(<AddonManager />);
const label = await screen.findByText('Provider');
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
const tile = label.closest('article');
expect(tile).not.toBeNull();
expect(tile!.textContent).toContain('AI Parsing');
});
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
@@ -615,7 +685,7 @@ describe('AddonManager', () => {
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Connection');
await screen.findByText('Provider');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
+264 -351
View File
@@ -1,14 +1,20 @@
import { useEffect, useState, type ComponentType } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useIsDark } from '../../hooks/useIsDark'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import EmptyState from '../shared/EmptyState'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
// used to land on the fallback.
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -60,6 +66,14 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
return <Icon size={size} />
}
/** What each type means, shown on the tile itself now that the sections are gone. The
* hint rides along as the tooltip, so "Global" still explains itself. */
const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelKey: string; hintKey: string }> = {
trip: { icon: Briefcase, labelKey: 'admin.addons.type.trip', hintKey: 'admin.addons.tripHint' },
global: { icon: Globe, labelKey: 'admin.addons.type.global', hintKey: 'admin.addons.globalHint' },
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
}
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
const COLLAB_SUB_FEATURES = [
@@ -71,42 +85,43 @@ const COLLAB_SUB_FEATURES = [
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
const { t } = useTranslation()
const dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const dark = useIsDark()
const toast = useToast()
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
const [addons, setAddons] = useState<Addon[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadAddons()
loadAddons().finally(() => setLoading(false))
}, [])
const loadAddons = async () => {
setLoading(true)
try {
const data = await adminApi.addons()
setAddons(data.addons)
} catch (err: unknown) {
toast.error(t('admin.addons.toast.error'))
} finally {
setLoading(false)
}
}
/** Optimistic flip, per-row rollback on failure. Rolling the whole addons
* snapshot back instead would undo a toggle the user hit in parallel. */
const handleToggle = async (addon: Addon) => {
const newEnabled = !addon.enabled
// Optimistic update
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
try {
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
refreshGlobalAddons()
toast.success(t('admin.addons.toast.updated'))
} catch (err: unknown) {
// Rollback
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
toast.error(t('admin.addons.toast.error'))
return
}
refreshGlobalAddons()
// Journey off disables every photo provider with it, and the response carries
// only Journey. Without re-reading, switching Journey back on brings the shelf
// up with providers the database has long since turned off.
if (addon.id === 'journey') await loadAddons()
toast.success(t('admin.addons.toast.updated'))
}
const isPhotoProviderAddon = (addon: Addon) => {
@@ -118,24 +133,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
}
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
const enableProvider = !providerAddon.enabled
const prev = addons
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
try {
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
refreshGlobalAddons()
toast.success(t('admin.addons.toast.updated'))
} catch {
setAddons(prev)
toast.error(t('admin.addons.toast.error'))
}
}
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
const globalAddons = addons.filter(a => a.type === 'global')
const integrationAddons = addons.filter(a => a.type === 'integration')
@@ -144,173 +142,160 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleTogglePhotoProvider(provider),
toggle: () => handleToggle(provider),
}))
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
if (loading) {
return (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
<div className="grid place-items-center py-16">
<Loader2 size={22} className="animate-spin text-content-faint" />
</div>
)
}
/** Bag tracking, the collab features and the photo providers all hang off their
* parent as shelf rows, and each shelf only shows while its parent is on —
* a provider toggled under a disabled Journey would hit the server's 409. */
const shelfFor = (addon: Addon) => {
if (addon.id === 'packing' && addon.enabled && onToggleBagTracking) {
return (
<AddonSubRow
icon={<Luggage size={14} />}
title={t('admin.bagTracking.title')}
description={t('admin.bagTracking.subtitle')}
enabled={!!bagTrackingEnabled}
onToggle={onToggleBagTracking}
/>
)
}
if (addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature) {
return COLLAB_SUB_FEATURES.map(feat => {
const Icon = feat.icon
return (
<AddonSubRow
key={feat.key}
icon={<Icon size={14} />}
title={t(feat.titleKey)}
description={t(feat.subtitleKey)}
enabled={collabFeatures[feat.key]}
onToggle={() => onToggleCollabFeature(feat.key)}
/>
)
})
}
if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) {
return providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<AddonSubRow
key={provider.key}
icon={ProviderIcon ? <ProviderIcon size={14} /> : undefined}
title={provider.label}
description={provider.description}
enabled={provider.enabled}
onToggle={provider.toggle}
/>
)
})
}
// The AI-parsing settings hang off their tile like every other sub-feature,
// just as a form instead of toggle rows.
if (addon.id === 'llm_parsing' && addon.enabled) {
return <LlmParsingConfig addon={addon} />
}
return null
}
const tile = (addon: Addon) => {
const label = getAddonLabel(t, addon)
return (
<AddonTile
key={addon.id}
icon={<AddonIcon name={addon.icon} size={18} />}
name={label.name}
description={label.description}
enabled={addon.enabled}
onToggle={() => handleToggle(addon)}
>
{shelfFor(addon)}
</AddonTile>
)
}
/* One column per type, side by side, instead of three stacked sections. Stacked, each
section opened its own set of columns and the tall tiles (Collab 261px, Journey
185px) set a section's height while its neighbours ran out early. Side by side the
column heading carries the type, so the tiles need no badge of their own. */
const groups = [
{ key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0)
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="px-6 py-4 border-b border-edge-secondary">
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
<div className="space-y-5">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<div>
<h2 className="text-subtitle font-semibold tracking-tight text-content">{t('admin.addons.title')}</h2>
<p className="mt-1 flex flex-wrap items-center gap-1 text-caption text-content-muted">
{t('admin.addons.subtitleBefore')}
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, verticalAlign: 'middle', opacity: 0.7 }} />
{t('admin.addons.subtitleAfter')}
</p>
</div>
{addons.length === 0 ? (
<div className="p-8 text-center text-sm text-content-faint">
{t('admin.addons.noAddons')}
</div>
) : (
<div>
{/* Trip Addons */}
{tripAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Briefcase size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
</span>
</div>
{tripAddons.map(addon => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button type="button" onClick={onToggleBagTracking}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
)}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<div className="space-y-2">
{COLLAB_SUB_FEATURES.map(feat => {
const enabled = collabFeatures[feat.key]
const Icon = feat.icon
return (
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button type="button" onClick={() => onToggleCollabFeature(feat.key)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Global Addons */}
{globalAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Globe size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
</span>
</div>
{globalAddons.map(addon => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button type="button"
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Integration Addons */}
{integrationAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Link2 size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
</span>
</div>
{integrationAddons.map(addon => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'llm_parsing' && addon.enabled && (
<LlmParsingConfig addon={addon} />
)}
</div>
))}
</div>
)}
</div>
{totalCount > 0 && (
<span
className="shrink-0 text-caption tabular-nums text-content-faint"
title={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}
>
{enabledCount}/{totalCount}
</span>
)}
</div>
{addons.length === 0 ? (
<EmptyState scene="idle" title={t('admin.addons.noAddons')} />
) : (
<div className="grid grid-cols-1 items-start gap-x-4 gap-y-6 md:grid-cols-2 xl:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
return (
<section key={g.key}>
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
<div className="space-y-3">{g.addons.map(tile)}</div>
</section>
)
})}
</div>
)}
</div>
)
}
function GroupHead({ icon: Icon, label, hint, addons, t }: {
icon: ComponentType<{ size?: number; className?: string }>
label: string
hint: string
addons: Addon[]
t: (key: string, params?: Record<string, unknown>) => string
}) {
const enabled = addons.filter(a => a.enabled).length
return (
<div
className="mb-3 rounded-xl border border-edge-secondary bg-surface-secondary px-3 py-2"
title={t('admin.addons.group.count', { enabled, total: addons.length })}
>
<div className="flex items-center gap-2">
<Icon size={13} className="shrink-0 text-content-muted" />
<h3 className="min-w-0 flex-1 truncate text-caption font-semibold uppercase tracking-[0.06em] text-content">{label}</h3>
<span className="shrink-0 rounded-full bg-surface px-1.5 py-0.5 text-caption font-medium tabular-nums text-content-muted">
{enabled}/{addons.length}
</span>
</div>
{/* Says what the type means — the only place that still explains it. */}
<p className="mt-0.5 text-caption text-content-faint">{hint}</p>
</div>
)
}
@@ -408,9 +393,8 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
}
}
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-2.5 py-1.5 text-caption text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1 block text-caption font-medium text-content-secondary'
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
@@ -418,131 +402,113 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
/* Lives in the tile's shelf like the collab toggles, so everything is caption-
sized and single-column — the band this used to be had a whole page width. */
return (
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
<div className="max-w-2xl space-y-6">
<p className="text-xs text-content-faint">
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
</p>
<li className="space-y-2.5 py-1.5">
<p className="text-caption text-content-faint">
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
</p>
{/* Connection */}
<section className="space-y-3">
<div className={sectionCls}>Connection</div>
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-caption text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
)}
<label className="block">
<span className={labelCls}>Model</span>
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-2 rounded-lg border border-edge-secondary bg-surface p-2.5">
<div className="flex items-center justify-between">
<span className="text-caption font-medium text-content-secondary">Installed on the server</span>
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-caption text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
{modelsErr && <p className="text-caption text-danger">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-caption text-content-faint">No models installed yet — pull one below.</p>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
)}
</section>
{/* Model */}
<section className="space-y-3">
<div className={sectionCls}>Model</div>
<label className="block">
<span className="sr-only">Model</span>
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1">
{installed.map(name => (
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2 py-0.5 text-caption transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
</div>
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-xs text-content-faint">No models installed yet — pull one below.</p>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
))}
</div>
)}
<div className="border-t border-edge-secondary pt-3">
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-content">{m.label}</span>
{m.recommended && (
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
)}
</div>
<div className="text-xs text-content-faint">{m.note}</div>
{isPulling && (
<div className="mt-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
{installedHere ? (
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
)
})}
</div>
</div>
))}
</div>
)}
</section>
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
<div className="border-t border-edge-secondary pt-2">
<div className="mb-1.5 text-caption font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className="min-w-0" title={m.note}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-caption text-content">{m.label}</span>
{m.recommended && (
<span className="shrink-0 rounded-md bg-success-soft px-1.5 py-px text-caption font-semibold text-success">Recommended</span>
)}
{installedHere ? (
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-2 py-1 text-caption font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-2 py-1 text-caption font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
{isPulling && (
<div className="mt-1">
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-caption text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)}
<div className="flex justify-end">
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-3 py-1.5 text-caption font-medium text-accent-text transition-opacity disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</li>
)
}
interface AddonRowProps {
addon: Addon
onToggle: (addon: Addon) => void
t: (key: string) => string
statusOverride?: boolean
hideToggle?: boolean
}
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
const nameKey = `admin.addons.catalog.${addon.id}.name`
const descKey = `admin.addons.catalog.${addon.id}.description`
@@ -555,56 +521,3 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
}
}
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
const isComingSoon = false
const label = getAddonLabel(t, addon)
const displayName = nameOverride || label.name
const displayDescription = descriptionOverride || label.description
const enabledState = statusOverride ?? addon.enabled
return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
{/* Icon */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
<AddonIcon name={addon.icon} size={20} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-content">{displayName}</span>
{isComingSoon && (
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
Coming Soon
</span>
)}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
</span>
</div>
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
</div>
{/* Toggle */}
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
{!hideToggle && (
<button type="button"
onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
style={{
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
</button>
)}
</div>
</div>
)
}
@@ -0,0 +1,40 @@
import type { ReactNode } from 'react'
import ToggleSwitch from '../Settings/ToggleSwitch'
/**
* One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under
* Lists, a collab feature under Collab, a photo provider under Journey.
*
* A photo provider without a vendor mark has to stay free of `<svg>`
* (FE-ADMIN-ADDON-015), which is why `icon` falls back to a fixed-width spacer
* rather than a placeholder glyph. Nothing else belongs in this row either: no
* chevron, no status dot, no lucide fallback.
*
* The row carries its title only; the description rides along as the `title`
* tooltip, because a second line of text is what makes a child weigh as much as
* an addon.
*/
export default function AddonSubRow({
icon,
title,
description,
enabled,
onToggle,
}: {
/** Vendor mark or lucide glyph at 14px. Omit it and the slot keeps its width. */
icon?: ReactNode
title: string
description?: string
enabled: boolean
onToggle: () => void
}) {
return (
<li className="flex min-h-[36px] items-center gap-3">
{icon ? <span className="shrink-0 text-content-faint">{icon}</span> : <span className="w-3.5 shrink-0" aria-hidden />}
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-secondary" title={description}>
{title}
</span>
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
</li>
)
}
+80
View File
@@ -0,0 +1,80 @@
import type { ReactNode } from 'react'
import ToggleSwitch from '../Settings/ToggleSwitch'
/**
* One addon in the admin grid: a card whose fill carries the on/off state, with
* an optional shelf at the foot holding its sub-features.
*
* The fill ladder is `--bg-secondary` (on) against `--bg-card` (the panel it
* sits on) — that pair reads in both themes, because secondary is *darker* than
* the card in light and *lighter* in dark. The obvious alternative, on =
* `bg-surface-card`, inverts in dark and turns the disabled tiles into the loud
* ones.
*
* Hover and focus ride on border and fill rather than a shadow: `--shadow-card`
* is only declared in `:root`, so the `.dark` palette never overrides it and a
* shadow-based affordance would be invisible there.
*
* The card is not a click target — only the switches are. No lift, no pointer
* cursor, no `role="button"`.
*/
export default function AddonTile({
icon,
name,
description,
enabled,
onToggle,
children,
}: {
icon: ReactNode
name: string
description?: string
enabled: boolean
onToggle: () => void
/** The sub-shelf: `<AddonSubRow>` children. */
children?: ReactNode
}) {
return (
<article
className={`flex flex-col overflow-hidden rounded-xl border transition-[background-color,border-color] duration-150 focus-within:ring-2 focus-within:ring-accent ${
enabled
? 'border-edge bg-surface-secondary'
: 'border-edge-secondary bg-surface hover:border-edge hover:bg-surface-secondary'
}`}
>
<div className="flex items-start gap-3 p-4">
<div
className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl ${
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
}`}
>
{icon}
</div>
<div className="min-w-0 flex-1">
{/* The name stays full-strength when off: it is the tile's identity, not its
state. Greying it out reads as "broken" rather than "not enabled". */}
<h4 className="truncate text-body font-semibold text-content" title={name}>
{name}
</h4>
<p
className={`mt-0.5 line-clamp-2 text-caption ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
title={description}
>
{description}
</p>
</div>
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
</div>
{children && (
<div className="border-t border-edge-secondary bg-surface-tertiary px-4 py-2.5">
{/* Sitting inside the parent on its own fill is enough to say these belong
to it — no rail, no indent guides. */}
<ul className="space-y-0.5">{children}</ul>
</div>
)}
</article>
)
}
@@ -129,7 +129,9 @@ describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => {
* the UI. It must escape every overflow ancestor, and flip up when the bottom is tight.
*/
describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => {
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: false })
// operatorEgress so the menu shows every action, Allowed hosts included (it is
// gated on the declaration, like the row chip).
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: true })
const realRect = HTMLButtonElement.prototype.getBoundingClientRect
afterEach(() => { HTMLButtonElement.prototype.getBoundingClientRect = realRect })
@@ -281,6 +283,10 @@ describe('AdminPluginsPanel — a refused update', () => {
expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument()
expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
// The dialog body scrolls; the decision buttons sit outside it, so they never leave the viewport (#2159).
const body = screen.getByText(/key it is offering now/i).closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).not.toContainElement(screen.getByRole('button', { name: /trust the new key/i }))
})
// D2, at the UI. An invalid signature means the bytes are not what the author signed.
@@ -843,6 +849,30 @@ describe('AdminPluginsPanel — row actions', () => {
expect(screen.getByText('View error log')).toBeInTheDocument()
expect(screen.queryByText('Restart')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-PANEL-049: Allowed hosts is offered only to a plugin that declared operatorEgress', async () => {
// An admin must never be invited to widen egress for a plugin that didn't ask for it
// — same rule the row chip already follows.
await openRowMenu(plugin({ operatorEgress: false }))
expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
expect(screen.queryByText('Allowed hosts')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-PANEL-050: a long host list scrolls inside the dialog instead of growing past the viewport (#2159)', async () => {
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
HttpResponse.json({ supported: true, hosts: ['gotify.lan', 'ntfy.lan'] })))
await openRowMenu()
fireEvent.click(screen.getByText('Allowed hosts'))
// The card is capped and its body scrolls, so the input, the Add button and the
// restart note below the list stay reachable however many hosts the admin entered.
const body = (await screen.findByText('gotify.lan')).closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).toContainElement(screen.getByPlaceholderText('gotify.example.com'))
expect(body!.parentElement!.className).toContain('max-h-[88vh]')
})
})
describe('AdminPluginsPanel — enabling a plugin', () => {
@@ -947,7 +977,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => {
await screen.findByText('Gotify')
fireEvent.click(rowToggle('Gotify'))
fireEvent.click(await screen.findByRole('button', { name: /^cancel$/i }))
// The dependency list scrolls; Cancel sits outside it, so it never leaves the viewport (#2159).
const body = (await screen.findByText('trek-core')).closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).not.toContainElement(screen.getByRole('button', { name: /^cancel$/i }))
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument())
expect(installs).toBe(0)
@@ -967,6 +1001,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => {
// An unknown permission is still shown — as its raw code
expect(screen.getByText('weird:perm')).toBeInTheDocument()
expect(screen.getByText('api.acme.io')).toBeInTheDocument()
// A long permission list scrolls inside the dialog; the decision buttons sit outside
// the scroll container, so they never leave the viewport (#2159).
const body = screen.getByText('Read trips the acting user can access').closest('.overflow-y-auto')
expect(body).not.toBeNull()
expect(body).not.toContainElement(screen.getByRole('button', { name: /keep off for now/i }))
fireEvent.click(screen.getByRole('button', { name: /keep off for now/i }))
expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument()
@@ -1439,6 +1478,18 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-PANEL-041a: the card opts out of the global press-scale (#2158)', async () => {
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
// the card shifted the Install button out from under the pointer, so the click
// retargeted onto the card and opened the detail modal instead of installing.
// The data-no-press attribute is the pin.
discoverWith({})
render(<AdminPluginsPanel />)
await clickDiscover()
expect((await screen.findByText('Gotify')).closest('[role="button"]')).toHaveAttribute('data-no-press')
})
it('FE-COMP-PLUGINS-PANEL-042: the detail modal lists setup fields, metadata and a separate homepage', async () => {
discoverWith(
{ downloadCount: 999_500, reviewedAt: '2026-01-05T00:00:00Z', homepage: 'https://gotify.example', trek: '>=3.2.0' },
@@ -1763,7 +1814,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
it('FE-W5PLG-011: a plugin whose runtime cannot take extra hosts says so', async () => {
const user = userEvent.setup()
mockPanel([row()])
// Declares operatorEgress (so the menu item shows), but the runtime lookup
// disagrees — the dialog's unsupported notice is the truth-teller.
mockPanel([row({ operatorEgress: true })])
server.use(
http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] })),
)
@@ -1778,7 +1831,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
it('FE-W5PLG-012: a failing egress lookup still opens the dialog in the unsupported state', async () => {
const user = userEvent.setup()
mockPanel([row()])
mockPanel([row({ operatorEgress: true })])
server.use(
http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
)
@@ -2134,3 +2187,357 @@ describe('AdminPluginsPanel — update all', () => {
await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget']))
})
})
/**
* The admin-owned `scope:'instance'` settings form. The fields are declared manifest
* data (validated at install), the values live in the plugin's instance config, and a
* save may RESTART a running plugin — the child reads config once, at init — so the
* admin must be told when that happened rather than wondering why the plugin blinked.
*/
describe('AdminPluginsPanel — instance settings', () => {
const FIELDS = [
{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: true, secret: false },
{ key: 'apiKey', label: 'API key', input_type: 'text', required: false, secret: true },
]
async function openRowMenu(p: Record<string, unknown>) {
mockList(p)
render(<><ToastContainer /><AdminPluginsPanel /></>)
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
}
async function openSettings(config: Record<string, unknown> = { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' }) {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ fields: FIELDS, config })),
)
await openRowMenu(plugin({ instanceSettingsCount: 2 }))
fireEvent.click(screen.getByText('Instance settings'))
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
}
it('FE-COMP-PLUGINS-CFG-001: a plugin with instance fields offers the menu item, opening the form', async () => {
await openSettings()
// Both declared fields render, the secret as a masked password input.
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password')
})
it('FE-COMP-PLUGINS-CFG-002: a plugin with NO instance fields gets no menu item', async () => {
await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 0 }))
expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
expect(screen.queryByText('Instance settings')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-008: a plugin with instance ACTIONS but no fields still offers the menu item', async () => {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () =>
HttpResponse.json({ fields: [], config: {}, actions: [{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }] })),
)
await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 1 }))
expect(screen.getByText('Instance settings')).toBeInTheDocument()
fireEvent.click(screen.getByText('Instance settings'))
expect(await screen.findByRole('button', { name: 'Ping server' }, { timeout: 5000 })).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-003: saving sends the edits but never the untouched secret mask', async () => {
let body: Record<string, unknown> | null = null
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
body = await request.json() as Record<string, unknown>
return HttpResponse.json({ config: body, restarted: false })
}),
)
await openSettings()
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument()
// The mask is a display artifact, not a value — sending it would overwrite the stored secret.
expect(body).toEqual({ apiUrl: 'https://new.example' })
})
it('FE-COMP-PLUGINS-CFG-004: a save that restarted the running plugin says so', async () => {
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', () =>
HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })),
)
await openSettings()
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('Settings saved — plugin restarted', {}, { timeout: 5000 })).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-005: renders every declared field type and saves the edited values', async () => {
const RICH = [
{ key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }], hint: 'Pick one' },
{ key: 'enabled', input_type: 'checkbox' }, // no label — the key stands in
{ key: 'retries', label: 'Retries', input_type: 'number', required: true },
]
let body: Record<string, unknown> | null = null
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () =>
HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })),
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
body = await request.json() as Record<string, unknown>
return HttpResponse.json({ config: body, restarted: false })
}),
)
await openRowMenu(plugin({ instanceSettingsCount: 3 }))
fireEvent.click(screen.getByText('Instance settings'))
// Stored values land in the right controls; an unset value renders empty.
const select = await screen.findByRole('combobox', {}, { timeout: 5000 })
expect(select).toHaveValue('slow')
expect(screen.getByRole('checkbox')).toBeChecked()
expect(screen.getByRole('spinbutton')).toHaveValue(null)
expect(screen.getByText('Pick one')).toBeInTheDocument() // hint
expect(screen.getByText('enabled')).toBeInTheDocument() // label falls back to the key
fireEvent.change(select, { target: { value: 'fast' } })
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } })
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument()
expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' })
})
const ACTIONS = [
{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' },
{ key: 'purge', label: 'Purge cache', hint: 'Drops every cached tile', danger: true, scope: 'instance' },
]
async function openWithActions(p: Record<string, unknown> = plugin({ instanceSettingsCount: 2 })) {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () =>
HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com' }, actions: ACTIONS })),
)
await openRowMenu(p)
fireEvent.click(screen.getByText('Instance settings'))
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
}
it('FE-COMP-PLUGINS-ACT-010: instance actions render below the fields and run against the admin route', async () => {
let posted = ''
server.use(
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
posted = String(params.key)
return HttpResponse.json({ ok: true, message: 'pong' })
}),
)
await openWithActions()
expect(screen.getByText('Drops every cached tile')).toBeInTheDocument() // hint
fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
expect(await screen.findByText('pong', {}, { timeout: 5000 })).toBeInTheDocument()
expect(posted).toBe('ping')
// The dialog stays open — the admin may run another one.
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-ACT-011: a danger action asks first; confirming runs it', async () => {
let posted = ''
server.use(
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
posted = String(params.key)
return HttpResponse.json({ ok: false, message: 'cache locked' })
}),
)
await openWithActions()
fireEvent.click(screen.getByRole('button', { name: 'Purge cache' }))
expect(await screen.findByText('Run this action?')).toBeInTheDocument()
expect(posted).toBe('')
fireEvent.click(screen.getByRole('button', { name: /^confirm$/i }))
expect(await screen.findByText('cache locked', {}, { timeout: 5000 })).toBeInTheDocument()
expect(posted).toBe('purge')
})
it('FE-COMP-PLUGINS-ACT-012: an inactive plugin shows the buttons disabled with the activation hint', async () => {
await openWithActions(plugin({ instanceSettingsCount: 2, status: 'inactive', enabled: 0 }))
expect(screen.getByRole('button', { name: 'Ping server' })).toBeDisabled()
expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-ACT-013: a dirty form is saved before the action runs', async () => {
const calls: string[] = []
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', () => { calls.push('save'); return HttpResponse.json({ config: {}, restarted: false }) }),
http.post('*/api/admin/plugins/trek-gotify/actions/:key', () => { calls.push('run'); return HttpResponse.json({ ok: true }) }),
)
await openWithActions()
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
await waitFor(() => expect(calls).toEqual(['save', 'run']), { timeout: 5000 })
})
it('FE-COMP-PLUGINS-CFG-006: a failed config fetch is a toast, not a broken dialog', async () => {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
)
await openRowMenu(plugin({ instanceSettingsCount: 2 }))
fireEvent.click(screen.getByText('Instance settings'))
expect(await screen.findByText('Error', {}, { timeout: 5000 })).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-007: a rejected save shows the server reason and keeps the dialog open', async () => {
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', () =>
HttpResponse.json({ error: 'config refused' }, { status: 400 })),
)
await openSettings()
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument()
// Still open — the admin's edits are not thrown away on a refusal.
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-008: the dialog closes without saving', async () => {
await openSettings()
fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-009: pre-fills a declared default when no value is stored', async () => {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, secret: false, default: 'https://auth.openbnb.org/authorize' }],
config: {},
})),
)
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
fireEvent.click(screen.getByText('Instance settings'))
expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize', {}, { timeout: 5000 })).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-010: a stored value wins over the default', async () => {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: false, secret: false, default: 'https://auth.openbnb.org/authorize' }],
config: { oauth_authorize_url: 'https://mine.example' },
})),
)
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
fireEvent.click(screen.getByText('Instance settings'))
expect(await screen.findByDisplayValue('https://mine.example', {}, { timeout: 5000 })).toBeInTheDocument()
expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-CFG-011: blocks Save while a required field is empty and names it', async () => {
let called = false
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true, secret: false }],
config: {},
})),
http.put('*/api/admin/plugins/trek-gotify/config', () => { called = true; return HttpResponse.json({ config: {}, restarted: false }) }),
)
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
fireEvent.click(screen.getByText('Instance settings'))
await screen.findByText('Client ID', {}, { timeout: 5000 })
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('"Client ID" is required', {}, { timeout: 5000 })).toBeInTheDocument()
expect(called).toBe(false)
})
it('FE-COMP-PLUGINS-CFG-012: Save paints its label with the accent-text token, so a light accent stays readable', async () => {
await openSettings()
// `--accent-text` flips per scheme and per light/dark; a hard white label vanishes on
// the near-white accent the dark theme uses by default.
const save = screen.getByRole('button', { name: /^save$/i })
expect(save.className).toContain('text-accent-text')
expect(save.className).not.toContain('text-white')
})
})
/**
* TREK_PLUGINS_IGNORE_TREK_RANGE. The server turns its version gates into warnings; the
* panel's job is to make sure the admin actually SEES the warning — before a registry
* install, after a sideload/link that could not be asked first, and on the row for as
* long as the plugin keeps running outside the range its author vouched for.
*/
describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
function bypassPanel(plugins: Record<string, unknown>[], entry: Record<string, unknown> | null) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, ignoreTrekRange: true, plugins })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
)
}
const incompatibleEntry = () => registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
it('FE-PLG-BYPASS-001: says so in the header', async () => {
bypassPanel([plugin()], null)
render(<AdminPluginsPanel />)
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
})
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
let posted: unknown = null
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
posted = await request.json()
return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' } })
}))
withToast()
fireEvent.click(await screen.findByText('Discover'))
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
expect(btn).toBeEnabled()
fireEvent.click(btn)
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
expect(within(dialog).getByText(/corrupt trek data/i)).toBeInTheDocument()
expect(posted).toBeNull() // nothing sent until the admin accepts the risk
fireEvent.click(within(dialog).getByRole('button', { name: /^install anyway$/i }))
await waitFor(() => expect(posted).toEqual({ id: 'trek-gotify' }))
})
it('FE-PLG-BYPASS-003: cancelling the warning installs nothing', async () => {
let posted = false
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
withToast()
fireEvent.click(await screen.findByText('Discover'))
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
await waitFor(() => expect(screen.queryByRole('dialog', { name: /outside its supported trek versions/i })).not.toBeInTheDocument())
expect(posted).toBe(false)
})
it('FE-PLG-BYPASS-004: a sideload that landed outside its range is warned about afterwards', async () => {
bypassPanel([plugin()], null)
server.use(http.post('*/api/admin/plugins/upload', () =>
HttpResponse.json({ id: 'trek-new', version: '1.0.0', replaced: false, trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' } })))
const { container } = withToast()
await screen.findByText('Gotify')
await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, new File(['zip'], 'plugin.zip', { type: 'application/zip' }))
const dialog = await screen.findByRole('dialog', { name: /installed outside its supported trek versions/i })
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
expect(within(dialog).getByText(/trek-new/)).toBeInTheDocument()
})
it('FE-PLG-BYPASS-005: a plugin running outside its range keeps a warning chip on its row', async () => {
bypassPanel([plugin({
dependencyStatus: 'ok', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0',
trekRangeBypassed: { trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' },
})], null)
render(<AdminPluginsPanel />)
expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument()
})
})
+249 -42
View File
@@ -7,9 +7,12 @@ import {
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, Bot,
Loader2,
} from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
import { adminApi } from '../../api/client'
import { useInstanceSettings } from './useInstanceSettings'
import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from './useRangeBypass'
import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -49,6 +52,12 @@ interface PluginRow {
operatorEgress?: boolean
/** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */
egressHostCount?: number
/** How many `scope:'instance'` settings fields the plugin declares — gates the
* "Instance settings" menu item without a per-plugin fetch. */
instanceSettingsCount?: number
/** How many `scope:'instance'` actions the plugin declares — a plugin with actions
* but no settings fields still needs the menu item to run them. */
instanceActionsCount?: number
dependencies?: PluginDependencies
dependencyStatus?: DependencyStatus
dependencyIssues?: DependencyIssues
@@ -56,6 +65,8 @@ interface PluginRow {
trekRange?: string | null
/** The TREK this server is running — the server does the semver, the client just shows it. */
hostVersion?: string
/** Non-null while the plugin runs outside its declared range on the operator's say-so. */
trekRangeBypassed?: TrekRangeBypass | null
/** The author's signature was verified and their key pinned at install. False means the
* bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
signed?: boolean
@@ -265,7 +276,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
return out
}
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean }
// A plugin's declared dependencies as chips — a required addon (amber when that
// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
@@ -283,6 +294,8 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
blocked: true,
})
}
const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker
if (bypassed) out.push(bypassed)
for (const a of p.dependencies?.requiredAddons ?? []) {
out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
}
@@ -302,11 +315,12 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
* has outrun this TREK but an older one still fits, offer THAT version rather than a dead
* grey button. The plugin is perfectly usable, just not at its newest.
*/
function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } {
function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } {
if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
const title = item.trek
? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
: t('admin.plugins.dep.trekUnknown')
if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway"
if (item.latestCompatible) {
return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
}
@@ -390,6 +404,8 @@ export default function AdminPluginsPanel() {
const toast = useToast()
const [runtimeOn, setRuntimeOn] = useState(false)
const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK)
const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side
const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell
const [linkPath, setLinkPath] = useState('')
const [plugins, setPlugins] = useState<PluginRow[]>([])
const [loading, setLoading] = useState(true)
@@ -408,6 +424,8 @@ export default function AdminPluginsPanel() {
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null)
// The admin-owned scope:'instance' settings form — shared logic with the phone shell.
const settings = useInstanceSettings()
const [egressDraft, setEgressDraft] = useState('')
const [egressSaving, setEgressSaving] = useState(false)
const [egressError, setEgressError] = useState('')
@@ -462,9 +480,10 @@ export default function AdminPluginsPanel() {
// (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
void usePluginStore.getState().loadPlugins()
adminApi.plugins()
.then((d: { enabled: boolean; devLink?: boolean; plugins: PluginRow[] }) => {
.then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => {
setRuntimeOn(!!d.enabled)
setDevLink(!!d.devLink)
setIgnoreTrekRange(!!d.ignoreTrekRange)
setPlugins(d.plugins || [])
if ((d.plugins || []).length) {
adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
@@ -530,6 +549,7 @@ export default function AdminPluginsPanel() {
const res = await adminApi.pluginUpload(file)
setView('installed')
toast.success(t('admin.plugins.uploaded', { name: res.id }))
bypass.notice(res.id, res.trekRangeBypassed)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
} finally {
@@ -573,6 +593,11 @@ export default function AdminPluginsPanel() {
}
}
const openInstanceSettings = (p: { id: string; status: string }) => {
setMenu(null)
settings.open(p.id, p.status === 'active')
}
const openErrors = (id: string) => {
setMenu(null)
adminApi.pluginErrors(id)
@@ -595,7 +620,11 @@ export default function AdminPluginsPanel() {
const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
return range ? { version: reg.latest, range } : null
}
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
// `warn` is the pre-install confirm for an entry the registry already flagged as
// incompatible; an artifact whose OWN manifest turns out to be out of range (the index
// was only a pre-download filter) is caught by the marker on the response instead.
const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () =>
act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), t('admin.plugins.installed')))
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
// same busy/toast/refresh loop as uploadPlugin; the server gates it.
@@ -608,6 +637,7 @@ export default function AdminPluginsPanel() {
setView('installed')
setLinkPath('')
toast.success(t('admin.plugins.devLinkLinked', { id: res.id }))
bypass.notice(res.id, res.trekRangeBypassed)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
} finally {
@@ -667,9 +697,10 @@ export default function AdminPluginsPanel() {
if (busy === parent.id) return
setBusy(parent.id)
adminApi.pluginInstall(depId, { constraint, withDependencies: true })
.then((r: { installed?: string[]; requiredAddons?: string[] }) => {
.then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
bypass.notice(depId, r?.trekRangeBypassed)
return attemptActivate(parent)
})
// The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
@@ -687,9 +718,10 @@ export default function AdminPluginsPanel() {
const runUpdate = (p: PluginRow, version?: string) => {
setBusy(p.id); setMenu(null)
return adminApi.pluginUpdate(p.id, version)
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
bypass.notice(p.name, r.trekRangeBypassed)
})
.catch(e => {
const { error, code } = errBody(e)
@@ -813,11 +845,19 @@ export default function AdminPluginsPanel() {
<h2 className="text-lg font-semibold tracking-tight text-content">{t('admin.plugins.title')}</h2>
<p className="text-xs mt-1 text-content-muted max-w-xl">{t('admin.plugins.subtitle')}</p>
</div>
{runtimeOn && (
<span className="inline-flex items-center gap-2 shrink-0 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
</span>
)}
<div className="flex items-center gap-2 shrink-0">
{runtimeOn && ignoreTrekRange && (
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-warning bg-warning-soft px-2.5 py-1.5 rounded-full"
title={t('admin.plugins.rangeBypass.pillHint')}>
<AlertTriangle size={12} /> {t('admin.plugins.rangeBypass.pill')}
</span>
)}
{runtimeOn && (
<span className="inline-flex items-center gap-2 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
</span>
)}
</div>
</div>
</div>
@@ -927,7 +967,7 @@ export default function AdminPluginsPanel() {
) : error ? (
<div className="py-14 text-center text-sm text-danger">{t('admin.plugins.loadError')}</div>
) : !runtimeOn ? null : view === 'discover' ? (
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds}
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds} ignoreTrekRange={ignoreTrekRange}
onInstall={install} onOpenDetail={setDetailFor} filtered={anyFilter} />
) : plugins.length === 0 ? (
<EmptyState t={t} onDiscover={openDiscover} />
@@ -962,6 +1002,7 @@ export default function AdminPluginsPanel() {
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
})}
onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)}
onSettings={() => openInstanceSettings(p)}
onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
))}
</div>
@@ -972,10 +1013,12 @@ export default function AdminPluginsPanel() {
{/* Registry detail dialog */}
{detailFor && (
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy}
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy} ignoreTrekRange={ignoreTrekRange}
installed={installedIds.has(detailFor.id)} onInstall={install} onClose={() => setDetailFor(null)} />
)}
{bypass.copy && <RangeBypassDialog copy={bypass.copy} t={t} onConfirm={bypass.confirm} onClose={bypass.dismiss} />}
{/* Error-log modal */}
{errorsFor && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
@@ -1001,12 +1044,12 @@ export default function AdminPluginsPanel() {
{/* Operator-supplied egress hosts */}
{egressFor && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg max-h-[88vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
<div className="shrink-0 px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
<span className="text-sm font-semibold text-content flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>
<button type="button" onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
</div>
<div className="p-5 space-y-3">
<div className="p-5 space-y-3 min-h-0 flex-1 overflow-y-auto">
{!egressFor.supported ? (
<p className="text-sm text-content-faint">{t('admin.plugins.allowedHosts.unsupported')}</p>
) : (
@@ -1048,6 +1091,110 @@ export default function AdminPluginsPanel() {
</div>
)}
{/* Instance-wide settings (the admin-owned scope:'instance' fields) */}
{settings.form && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={settings.close}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg max-h-[80vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
<span className="text-sm font-semibold text-content flex items-center gap-2"><SlidersHorizontal size={15} /> {settings.form.id} — {t('admin.plugins.instanceSettings')}</span>
<button type="button" aria-label={t('common.close')} onClick={settings.close} className="text-content-faint hover:text-content"><X size={16} /></button>
</div>
<div className="p-5 space-y-4 overflow-y-auto">
{settings.form.fields.map(f => (
<label key={f.key} className="block">
<span className="block text-sm font-medium text-content-secondary mb-1">
{f.label || f.key}{f.required && <span className="text-danger"> *</span>}
</span>
{f.input_type === 'checkbox' ? (
<input
type="checkbox"
checked={settings.form.values[f.key] === true}
onChange={e => settings.setValue(f.key, e.target.checked)}
className="h-4 w-4 rounded border-edge"
/>
) : f.input_type === 'select' && f.options ? (
<select
value={String(settings.form.values[f.key] ?? '')}
onChange={e => settings.setValue(f.key, e.target.value)}
className="w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
>
<option value="">—</option>
{f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : (
<input
type={f.secret ? 'password' : (f.input_type === 'number' ? 'number' : 'text')}
value={String(settings.form.values[f.key] ?? '')}
placeholder={f.placeholder || ''}
autoComplete={f.secret ? 'new-password' : 'off'}
onChange={e => settings.setValue(f.key, e.target.value)}
className="w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
/>
)}
{f.hint && <span className="block text-xs text-content-muted mt-1">{f.hint}</span>}
</label>
))}
{(() => {
const form = settings.form
if (!form || form.actions.length === 0) return null
return (
<div className="border-t border-edge-secondary pt-4">
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
{t('admin.plugins.actions')}
</span>
{!form.active && (
<p className="text-xs text-content-muted mb-2">{t('admin.plugins.actions.inactive')}</p>
)}
<div className="flex flex-col gap-2">
{form.actions.map(a => {
const res = settings.actionResult[a.key]
return (
<div key={a.key} className="flex flex-wrap items-center gap-2">
<button type="button"
onClick={() => settings.runAction(a)}
disabled={!form.active || settings.runningAction !== null || settings.saving}
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
a.danger ? 'border-danger text-danger' : 'border-edge text-content'
}`}
>
{settings.runningAction === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
{a.label}
</button>
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
{res && (
<span aria-live="polite" className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
{res.message || (res.ok ? t('common.success') : t('common.error'))}
</span>
)}
</div>
)
})}
</div>
</div>
)
})()}
{settings.error && <p className="text-xs text-danger">{settings.error}</p>}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary flex justify-end">
<button type="button"
disabled={settings.saving || settings.runningAction !== null}
onClick={() => void settings.save()}
className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-text disabled:opacity-60"
>{t('common.save')}</button>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={settings.pendingAction !== null}
onClose={settings.cancelPendingAction}
onConfirm={settings.confirmPendingAction}
title={settings.pendingAction?.label ?? ''}
message={t('admin.plugins.actions.confirm')}
confirmLabel={t('common.confirm')}
/>
<ConfirmDialog
isOpen={!!confirmUninstall}
onClose={() => setConfirmUninstall(null)}
@@ -1185,11 +1332,11 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
)
}
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: {
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
onErrors: () => void; onEgress: () => void; onUninstall: () => void
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
}) {
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
const deps = deriveDeps(p, t)
@@ -1304,8 +1451,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-1.5">
{deps.map((d, i) => (
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
d.blocked ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
<d.icon size={12} className={d.blocked ? 'text-warning' : 'text-content-muted'} />{d.label}
d.blocked || d.warn ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} />{d.label}
</span>
))}
</div>
@@ -1334,8 +1481,18 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
{p.enabled === 1 && (
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
)}
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
an action-only plugin still needs the dialog to run its buttons. */}
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
never be invited to widen egress for a plugin that didn't ask for it
(same rule the row's egress chip follows). */}
{p.operatorEgress && (
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
)}
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
@@ -1405,14 +1562,15 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
)
}
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered, ignoreTrekRange }: {
items: RegistryItem[] | null
onInstall: (id: string, version?: string) => void
onInstall: (id: string, version?: string, warn?: RangeWarning) => void
onOpenDetail: (item: RegistryItem) => void
busy: string | null
t: T
installedIds: Set<string>
filtered: boolean
ignoreTrekRange: boolean
}) {
if (!items) return <div className="py-14 text-center text-sm text-content-faint">{t('common.loading')}</div>
if (items.length === 0) return (
@@ -1425,9 +1583,13 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 px-4 sm:px-6 pb-5 pt-1">
{items.map(item => {
const installed = installedIds.has(item.id)
const offer = installOffer(item, t)
const offer = installOffer(item, t, ignoreTrekRange)
return (
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
// No press-scale on the card: shrinking it mid-click slides the Install
// button out from under the pointer, so the click retargets onto the card
// and opens the detail modal instead of installing (#2158).
data-no-press
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
className="group border border-edge rounded-2xl bg-surface-card overflow-hidden flex flex-col cursor-pointer hover:-translate-y-0.5 hover:shadow-elevated hover:border-edge-faint transition-all duration-150">
<div className="relative">
@@ -1456,7 +1618,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<Download size={11} /> {formatCompactCount(item.downloadCount)}
</span>
)}
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version, offer.warn) }}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
@@ -1486,9 +1648,54 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
: <code className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded">{perm}</code>
}
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
item: RegistryItem; installed: boolean; busy: string | null
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
/**
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
* component rather than ConfirmDialog because the notice mode has no cancel button.
*/
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
}) {
return createPortal(
<div role="presentation" className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="dialog" aria-modal="true" aria-label={title}
className="bg-surface-card border border-edge rounded-2xl w-full max-w-md p-6 shadow-modal" onClick={e => e.stopPropagation()}>
<div className="flex items-start gap-4">
<div className="shrink-0 w-10 h-10 rounded-full bg-warning-soft grid place-items-center">
<AlertTriangle size={20} className="text-warning" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-content">{title}</h3>
<p className="mt-1.5 text-sm text-content-secondary leading-relaxed">{body}</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
{confirm ? (
<>
<button type="button" onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg text-content-secondary border border-edge-secondary transition-colors">
{t('common.cancel')}
</button>
<button type="button" onClick={onConfirm}
className="px-4 py-2 text-sm font-semibold rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
{t('admin.plugins.installAnyway')}
</button>
</>
) : (
<button type="button" onClick={onClose}
className="px-4 py-2 text-sm font-semibold rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">
{t('common.ok')}
</button>
)}
</div>
</div>
</div>,
document.body,
)
}
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange }: {
item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const [detail, setDetail] = useState<RegistryDetail | null>(null)
const [failed, setFailed] = useState(false)
@@ -1511,7 +1718,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
// The detail fetch carries the same compat verdict as the browse list; prefer it once
// it lands (it is keyed to the same entry) and fall back to the grid item until then.
const offer = installOffer(detail ?? item, t)
const offer = installOffer(detail ?? item, t, ignoreTrekRange)
return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
@@ -1533,7 +1740,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
</div>
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
</div>
<button type="button" onClick={() => onInstall(item.id, offer.version)}
<button type="button" onClick={() => onInstall(item.id, offer.version, offer.warn)}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
@@ -1793,15 +2000,15 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
<div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.sig.title', { name: data.subject.name })}</h3>
<p className="text-xs text-content-muted mt-1">{t(bodyKey as never)}</p>
</div>
</div>
<div className="p-5 space-y-4">
<div className="p-5 space-y-4 min-h-0 flex-1 overflow-y-auto">
{/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the
new one back to the author over the phone. The full key travels in the request. */}
{canRetrust && (
@@ -1815,7 +2022,7 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
<p className="text-xs text-content-faint font-mono break-all bg-surface-tertiary rounded-lg px-3 py-2">{data.detail}</p>
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<button type="button" onClick={onClose}
className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
@@ -1857,15 +2064,15 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
}) {
return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div>
<div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.updateConsentTitle')}</h3>
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}</p>
</div>
</div>
<div className="p-5 space-y-4">
<div className="p-5 space-y-4 min-h-0 flex-1 overflow-y-auto">
{/* The admin is about to widen what this code may do — so say, right here, that
nothing ties this code to its author. One line, no checkbox, no extra click:
this informs, it does not block. */}
@@ -1894,7 +2101,7 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
</div>
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<button type="button" onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button>
<button type="button" onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button>
</div>
@@ -1917,15 +2124,15 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
]
return (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
<div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.dep.resolveTitle')}</h3>
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}</p>
</div>
</div>
<div className="p-5 space-y-2.5">
<div className="p-5 space-y-2.5 min-h-0 flex-1 overflow-y-auto">
{rows.map(r => (
<div key={r.id} className="flex items-center gap-3 p-3 rounded-xl border border-edge-secondary bg-surface-tertiary">
<div className="min-w-0 flex-1">
@@ -1946,7 +2153,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
<p className="text-[11.5px] text-content-faint pt-1">{t('admin.plugins.dep.resolveHint')}</p>
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
<button type="button" onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
</div>
</div>
@@ -43,6 +43,7 @@ type Defaults = {
blur_booking_codes?: boolean
map_tile_url?: string
carto_api_key?: string
routing_base_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
@@ -119,6 +120,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [routingBase, setRoutingBase] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
@@ -128,6 +130,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setRoutingBase(data.routing_base_url || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
@@ -150,6 +153,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'routing_base_url') setRoutingBase('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
@@ -365,6 +369,32 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
</div>
)}
{/* Instance configuration, not a taste, which is why it sits here rather
than in a user's own Map settings. The origin has to appear in the CSP
`connect-src` the server emits at boot, and that list is built from
this default — a value on a personal settings row is read by the route
calculator and then refused by the browser, so the whole app stops
routing with no error it could report. The write route refuses one
from a non-admin for the same reason. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.routingBase')}
<ResetButton field="routing_base_url" />
</label>
<input
type="text"
value={routingBase}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
onBlur={() => save({ routing_base_url: routingBase.trim() })}
placeholder="https://osrm.example.org"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
</div>
)}
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
@@ -0,0 +1,152 @@
// FE-COMP-PLUGINS-ACT-001 to -006 — the shared instance-settings hook, action side.
import { act, renderHook, waitFor } from '@testing-library/react'
import { adminApi } from '../../api/client'
import { useInstanceSettings } from './useInstanceSettings'
vi.mock('../shared/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn() }) }))
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }))
const FIELDS = [{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: false, secret: false }]
const PURGE = { key: 'purge', label: 'Purge', danger: true, scope: 'instance' as const }
const PING = { key: 'ping', label: 'Ping', danger: false, scope: 'instance' as const }
beforeEach(() => {
vi.spyOn(adminApi, 'pluginConfig').mockResolvedValue({ fields: FIELDS, config: { apiUrl: 'https://a.example' }, actions: [PING, PURGE] })
vi.spyOn(adminApi, 'pluginSaveConfig').mockResolvedValue({ config: { apiUrl: 'https://b.example' }, restarted: false })
vi.spyOn(adminApi, 'runPluginAction').mockResolvedValue({ ok: true, message: 'pong' })
})
afterEach(() => vi.restoreAllMocks())
async function opened(active = true) {
const hook = renderHook(() => useInstanceSettings())
act(() => hook.result.current.open('p', active))
await waitFor(() => expect(hook.result.current.form).not.toBeNull())
return hook
}
/** A promise this test controls the settling of, to pin down state mid-flight. */
function deferred<T>() {
let resolve!: (v: T) => void
const promise = new Promise<T>(r => { resolve = r })
return { promise, resolve }
}
describe('useInstanceSettings — actions', () => {
it('FE-COMP-PLUGINS-ACT-001: open carries the instance actions, the active flag, and a clean form', async () => {
const { result } = await opened()
expect(result.current.form?.actions).toEqual([PING, PURGE])
expect(result.current.form?.active).toBe(true)
expect(result.current.form?.dirty).toBe(false)
})
it('FE-COMP-PLUGINS-ACT-002: a clean form runs the action straight away and keeps the dialog open', async () => {
const { result } = await opened()
act(() => result.current.runAction(PING))
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }))
expect(adminApi.pluginSaveConfig).not.toHaveBeenCalled()
expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping')
expect(result.current.form).not.toBeNull()
expect(result.current.runningAction).toBeNull()
})
it('FE-COMP-PLUGINS-ACT-003: a dirty form saves first, then runs, and is clean afterwards', async () => {
const { result } = await opened()
act(() => result.current.setValue('apiUrl', 'https://b.example'))
expect(result.current.form?.dirty).toBe(true)
act(() => result.current.runAction(PING))
await waitFor(() => expect(result.current.actionResult.ping).toBeDefined())
expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://b.example' })
expect(vi.mocked(adminApi.pluginSaveConfig).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(adminApi.runPluginAction).mock.invocationCallOrder[0])
expect(result.current.form?.dirty).toBe(false)
expect(result.current.form).not.toBeNull()
})
it('FE-COMP-PLUGINS-ACT-004: a save failure surfaces in error and the action never runs', async () => {
vi.mocked(adminApi.pluginSaveConfig).mockRejectedValue({ response: { data: { error: 'nope' } } })
const { result } = await opened()
act(() => result.current.setValue('apiUrl', 'x'))
act(() => result.current.runAction(PING))
await waitFor(() => expect(result.current.error).toBe('nope'))
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
})
it('FE-COMP-PLUGINS-ACT-005: a danger action waits for confirmation; cancel drops it, confirm runs it', async () => {
const { result } = await opened()
act(() => result.current.runAction(PURGE))
expect(result.current.pendingAction).toEqual(PURGE)
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
act(() => result.current.cancelPendingAction())
expect(result.current.pendingAction).toBeNull()
act(() => result.current.runAction(PURGE))
act(() => result.current.confirmPendingAction())
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'purge'))
expect(result.current.pendingAction).toBeNull()
})
it('FE-COMP-PLUGINS-ACT-006: an inactive plugin never posts, and a failed result is stored as such', async () => {
const { result } = await opened(false)
act(() => result.current.runAction(PING))
await new Promise(r => setTimeout(r, 0))
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
vi.mocked(adminApi.runPluginAction).mockResolvedValue({ ok: false, message: 'credentials rejected' })
const active = await opened(true)
act(() => active.result.current.runAction(PING))
await waitFor(() => expect(active.result.current.actionResult.ping).toEqual({ ok: false, message: 'credentials rejected' }))
})
it('FE-COMP-PLUGINS-ACT-007: an edit made while the pre-action save is in flight is not clobbered when dirty clears', async () => {
const savePromise = deferred<{ config: Record<string, unknown>; restarted: boolean }>()
vi.mocked(adminApi.pluginSaveConfig).mockReturnValue(savePromise.promise)
const { result } = await opened()
act(() => result.current.setValue('apiUrl', 'https://a-edit.example'))
act(() => result.current.runAction(PING))
await waitFor(() => expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://a-edit.example' }))
// A concurrent edit lands while the save is still pending.
act(() => result.current.setValue('apiUrl', 'https://b-edit.example'))
await act(async () => {
savePromise.resolve({ config: { apiUrl: 'https://a-edit.example' }, restarted: false })
await savePromise.promise
})
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping'))
expect(result.current.form?.dirty).toBe(true)
expect(result.current.form?.values.apiUrl).toBe('https://b-edit.example')
})
it('FE-COMP-PLUGINS-ACT-008: an in-flight action from a closed dialog does not leak into the next one', async () => {
const runPromise = deferred<{ ok: boolean; message?: string }>()
vi.mocked(adminApi.runPluginAction).mockReturnValueOnce(runPromise.promise)
const { result } = await opened()
act(() => result.current.runAction(PING))
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping'))
act(() => result.current.close())
act(() => result.current.open('q', true))
await waitFor(() => expect(result.current.form?.id).toBe('q'))
expect(result.current.runningAction).toBeNull()
act(() => result.current.runAction(PING))
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('q', 'ping'))
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }))
// The stale request from the closed dialog settles after the fact — it must not overwrite the new dialog's result.
await act(async () => {
runPromise.resolve({ ok: true, message: 'stale pong' })
await runPromise.promise
})
expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' })
})
it('FE-COMP-PLUGINS-ACT-009: a 404 "Plugin is not active" from the server (DB active, no live child) shows the inactive message and flips active off', async () => {
vi.mocked(adminApi.runPluginAction).mockRejectedValue({ response: { status: 404, data: { error: 'Plugin is not active' } } })
const { result } = await opened(true)
expect(result.current.form?.active).toBe(true)
act(() => result.current.runAction(PING))
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: false, message: 'admin.plugins.actions.inactive' }))
expect(result.current.form?.active).toBe(false)
expect(result.current.runningAction).toBeNull()
})
})
@@ -0,0 +1,147 @@
import { useRef, useState } from 'react'
import { adminApi, type PluginAction, type PluginUserSettingField } from '../../api/client'
import type { PluginActionResult } from '@trek/shared'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm'
export interface InstanceSettingsForm {
id: string
fields: PluginUserSettingField[]
values: Record<string, string | boolean>
/** The plugin's `scope:'instance'` action buttons. */
actions: PluginAction[]
/** Whether the plugin is active: an action needs a running child to execute. */
active: boolean
/** An edit since open/last save. An action saves a dirty form before it runs. */
dirty: boolean
}
/**
* The admin-owned `scope:'instance'` settings form — the ONE logic path behind
* both admin shells (the desktop modal and the phone sheet render their own
* markup over this state). Open fetches the declared fields plus the stored
* (masked) values and the instance-scope actions; save skips an untouched secret
* mask so it never overwrites the stored ciphertext, and reports a restart when
* the server re-spawned a running plugin (the child reads its config once, at init).
*
* Actions run AS the clicking admin. A dirty form is saved first (so the action
* sees the config the admin is looking at), the dialog stays open, and the result
* shows beside the button. A `danger` action parks in `pendingAction` until the
* shell's confirm UI calls `confirmPendingAction`.
*/
export function useInstanceSettings() {
const { t } = useTranslation()
const toast = useToast()
const [form, setForm] = useState<InstanceSettingsForm | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [actionResult, setActionResult] = useState<Record<string, PluginActionResult>>({})
const [runningAction, setRunningAction] = useState<string | null>(null)
const [pendingAction, setPendingAction] = useState<PluginAction | null>(null)
// Bumped on every open()/close() so an in-flight action from a closed/replaced
// dialog can tell it's stale and bail without touching the new dialog's state.
const openSeq = useRef(0)
const open = (id: string, active: boolean) => {
openSeq.current++
setError('')
setActionResult({})
setPendingAction(null)
setRunningAction(null)
adminApi.pluginConfig(id)
.then(d => {
setForm({ id, fields: d.fields, values: seedSettingsValues(d.fields, d.config), actions: d.actions ?? [], active, dirty: false })
})
.catch(() => toast.error(t('common.error')))
}
const setValue = (key: string, value: string | boolean) =>
setForm(s => s && ({ ...s, values: { ...s.values, [key]: value }, dirty: true }))
const close = () => { openSeq.current++; setForm(null); setPendingAction(null); setRunningAction(null) }
/** Persist the form. Returns false when the save was refused (error already shown). */
const persist = async (f: InstanceSettingsForm): Promise<boolean> => {
const missing = findMissingRequired(f.fields, f.values)
if (missing) {
setError(t('admin.plugins.requiredMissing', { field: missing.label || missing.key }))
return false
}
setSaving(true); setError('')
try {
const d = await adminApi.pluginSaveConfig(f.id, settingsPatch(f.fields, f.values))
toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved'))
return true
} catch (e) {
const err = e as { response?: { data?: { error?: string } } }
setError(err.response?.data?.error || t('common.error'))
return false
} finally {
setSaving(false)
}
}
const save = async () => {
if (!form) return
if (await persist(form)) setForm(null)
}
const perform = async (a: PluginAction) => {
if (!form || !form.active || runningAction) return
const seq = openSeq.current
// Set BEFORE the dirty-save window, not just around the invoke call, so a second
// click (or the shells' Save button, see M7) is blocked for the whole run — not just
// once the pre-run save has already gone out.
setRunningAction(a.key)
try {
if (form.dirty) {
if (!(await persist(form))) return
if (openSeq.current !== seq) return
// Only clear dirty if the values are still the snapshot we just persisted —
// a reference check, since setValue always creates a new values object. A
// concurrent edit during the save produced a new object, so dirty stays true
// and the edit isn't silently dropped from the next save/action.
setForm(s => s && s.values === form.values ? { ...s, dirty: false } : s)
}
if (openSeq.current !== seq) return
const res = await adminApi.runPluginAction(form.id, a.key)
if (openSeq.current !== seq) return
setActionResult(r => ({ ...r, [a.key]: res }))
} catch (e) {
if (openSeq.current !== seq) return
const err = e as { response?: { status?: number; data?: { error?: string } } }
if (err.response?.status === 404 && err.response?.data?.error === 'Plugin is not active') {
// The DB `status` column said active when the dialog opened, but the server's
// live-process check (the supervisor's actual child map) disagrees — a stale
// row with no running child. Believe the server: show the real reason instead
// of a bare "Error", and disable the buttons instead of leaving them clickable.
setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('admin.plugins.actions.inactive') } }))
setForm(s => s && { ...s, active: false })
} else {
setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('common.error') } }))
}
} finally {
if (openSeq.current === seq) setRunningAction(null)
}
}
const runAction = (a: PluginAction) => {
if (!form || !form.active) return
if (a.danger) { setPendingAction(a); return }
void perform(a)
}
const confirmPendingAction = () => {
const a = pendingAction
setPendingAction(null)
if (a) void perform(a)
}
const cancelPendingAction = () => setPendingAction(null)
return {
form, saving, error, open, setValue, close, save,
actionResult, runningAction, pendingAction, runAction, confirmPendingAction, cancelPendingAction,
}
}
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { bypassChip, bypassOffer, rangeWarningCopy, useRangeBypass } from './useRangeBypass'
const t = (k: string, p?: Record<string, unknown>) => (p ? `${k}:${JSON.stringify(p)}` : k)
/**
* The ONE logic path behind the TREK_PLUGINS_IGNORE_TREK_RANGE warning in both admin
* shells — the desktop panel and the phone panel render their own markup over this.
*/
describe('rangeBypass helpers', () => {
it('bypassChip: a warning chip (not a blocker) only for a bypassed row', () => {
expect(bypassChip(null, t)).toBeNull()
expect(bypassChip(undefined, t)).toBeNull()
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' }, t)).toMatchObject({
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
})
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
})
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
expect(offer).toEqual({
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
})
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
})
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
const ranged = { name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0' }
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
confirm: true, title: 'admin.plugins.rangeBypass.title',
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
})
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
})
})
})
describe('useRangeBypass', () => {
it('guard runs straight through without a warning, and parks the run behind one otherwise', () => {
const { result } = renderHook(() => useRangeBypass())
let ran = 0
act(() => result.current.guard(undefined, () => { ran++ }))
expect(ran).toBe(1)
expect(result.current.warning).toBeNull()
act(() => result.current.guard({ name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
expect(ran).toBe(1) // nothing sent until the admin accepts
expect(result.current.copy?.confirm).toBe(true)
act(() => result.current.confirm())
expect(ran).toBe(2)
expect(result.current.warning).toBeNull()
})
it('dismiss drops a parked run; notice opens a plain notice only for a real marker', () => {
const { result } = renderHook(() => useRangeBypass())
let ran = 0
act(() => result.current.guard({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
act(() => result.current.dismiss())
expect(ran).toBe(0)
expect(result.current.warning).toBeNull()
act(() => result.current.notice('trek-new', null))
expect(result.current.warning).toBeNull()
act(() => result.current.notice('trek-new', { trekRange: null, hostVersion: '3.3.0' }))
expect(result.current.warning).toMatchObject({ name: 'trek-new', onConfirm: null })
expect(result.current.copy?.confirm).toBe(false)
act(() => result.current.confirm()) // a notice's confirm is just a close
expect(result.current.warning).toBeNull()
})
})
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
type T = (k: string, p?: Record<string, unknown>) => string
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
/**
* The chip a row carries while it runs outside its declared range on the operator's
* say-so. Not a blocker — the toggle works — but a warning that must outlive the install
* dialog for as long as the plugin runs here. Null when nothing was bypassed.
*/
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
if (!b) return null
return {
icon: AlertTriangle,
label: b.trekRange
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekBypassedUnknown'),
blocked: false,
warn: true,
}
}
/**
* The Install button for a registry entry the server flagged incompatible, once the
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
* regardless, so the button says exactly that — and carries what the warning has to say
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
*/
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
return {
blocked: false,
label: t('admin.plugins.installAnyway'),
title,
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
}
}
/**
* Title + body of the warning, for either mode: a CONFIRM step before a registry install
* the server would otherwise refuse (`onConfirm` set), or a plain NOTICE after a path that
* could not ask first — sideload, dev-link, update, dependency download. The copy is the
* whole point: the admin is accepting that the author never vouched for this TREK and
* that, in rare cases, that can cost them data.
*/
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
const confirm = w.onConfirm !== null
return {
confirm,
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
body: w.trekRange
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
}
}
/**
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning state behind BOTH admin shells (the desktop
* panel and the phone panel render their own dialog/sheet over it). `guard` parks a
* registry install behind the confirm step when the entry was already flagged
* incompatible; `notice` shows the after-the-fact warning for a response that carries the
* server's marker. Whether the switch is ON stays a plain `useState` in each shell: its
* setter is read inside the mount-time refresh, and only a useState setter is something
* the exhaustive-deps rule knows to be stable.
*/
export function useRangeBypass() {
const { t } = useTranslation()
const [warning, setWarning] = useState<RangeWarning | null>(null)
const notice = (name: string, b: TrekRangeBypass | null | undefined) => {
if (b) setWarning({ name, trekRange: b.trekRange, hostVersion: b.hostVersion, onConfirm: null })
}
const guard = (warn: RangeWarning | undefined, run: () => void) => {
if (warn) setWarning({ ...warn, onConfirm: run })
else run()
}
const confirm = () => { const w = warning; setWarning(null); w?.onConfirm?.() }
const dismiss = () => setWarning(null)
return {
warning,
copy: warning ? rangeWarningCopy(warning, t) : null,
notice, guard, confirm, dismiss,
}
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
describe('splitCents', () => {
it('splits evenly when it divides cleanly', () => {
@@ -16,8 +16,12 @@ describe('splitCents', () => {
expect(splitCents(50, 0)).toEqual([])
})
it('floors a negative amount at zero rather than inventing debt', () => {
expect(splitCents(-10, 2)).toEqual([0, 0])
it('splits a negative amount exactly instead of clamping it to zero (#2176)', () => {
// A refund spread across payers has to sum back too — the old Math.max(0, …)
// clamp silently swallowed the sign and broke rebalancePayers for refunds.
expect(splitCents(-10, 2)).toEqual([-5, -5])
const parts = splitCents(-100.01, 3)
expect(parts.reduce((a, b) => a + Math.round(b * 100), 0)).toBe(-10001)
})
})
@@ -75,6 +79,57 @@ describe('rebalancePayers', () => {
const next = rebalancePayers({ 1: '33.33' }, new Set([1]), new Set([1, 2, 3]), 100)
expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true)
})
// ── Refunds: negative totals rebalance like any other (#2176) ──────────────
it('spreads a negative total across payers so the parts stay typeable', () => {
const next = rebalancePayers({}, new Set(), new Set([1, 2]), -90)
expect(next).toEqual({ 1: '-45.00', 2: '-45.00' })
expect(payersBalanced(next, new Set([1, 2]), -90)).toBe(true)
})
it('lets a free payer absorb the rest of a negative total past a pinned amount', () => {
const next = rebalancePayers({ 1: '-70' }, new Set([1]), new Set([1, 2]), -100)
expect(next[1]).toBe('-70')
expect(next[2]).toBe('-30.00')
})
it('handles a pinned positive payer against a negative total', () => {
// Alice fronted 50 but the entry nets to -100 — the free payer carries -150.
const next = rebalancePayers({ 1: '50' }, new Set([1]), new Set([1, 2]), -100)
expect(next[2]).toBe('-150.00')
expect(payersBalanced(next, new Set([1, 2]), -100)).toBe(true)
})
})
// ── Client/server share parity (#2176) ───────────────────────────────────────
//
// splitEqualShares exists twice: here (previewing the split in euros) and on the
// server (netting the settlement in cents — BudgetService.splitEqualShares in
// server/src/nest/budget/budget.service.ts). The fixture below is duplicated
// verbatim in server/tests/unit/nest/budget.service.calc.test.ts; if either
// implementation drifts — sign handling included — its copy of this table fails.
const SHARE_PARITY_FIXTURE: { totalCents: number; users: number[]; itemId: number; expected: Record<number, number> }[] = [
{ totalCents: 10000, users: [1, 2, 3], itemId: 0, expected: { 1: 3334, 2: 3333, 3: 3333 } },
{ totalCents: 10000, users: [1, 2, 3], itemId: 1, expected: { 1: 3333, 2: 3334, 3: 3333 } },
{ totalCents: -10000, users: [1, 2, 3], itemId: 0, expected: { 1: -3333, 2: -3333, 3: -3334 } },
{ totalCents: -10000, users: [1, 2, 3], itemId: 1, expected: { 1: -3334, 2: -3333, 3: -3333 } },
{ totalCents: -101, users: [1, 2], itemId: 0, expected: { 1: -50, 2: -51 } },
{ totalCents: -101, users: [1, 2], itemId: 1, expected: { 1: -51, 2: -50 } },
{ totalCents: -1, users: [1, 2, 3], itemId: 0, expected: { 1: 0, 2: 0, 3: -1 } },
]
describe('splitEqualShares — server parity (#2176)', () => {
it.each(SHARE_PARITY_FIXTURE)(
'splits $totalCents cents across $users.length members (item $itemId) exactly like the server',
({ totalCents, users, itemId, expected }) => {
const shares = splitEqualShares(totalCents / 100, users.map(user_id => ({ user_id })), itemId)
const inCents = Object.fromEntries(Object.entries(shares).map(([id, v]) => [id, Math.round(v * 100)]))
expect(inCents).toEqual(expected)
// The invariant behind the fixture: the shares always sum back to the total.
expect(Object.values(inCents).reduce((a, b) => a + b, 0)).toBe(totalCents)
},
)
})
// ── Notes vs. itemized receipts (#1658) ──────────────────────────────────────
@@ -11,10 +11,24 @@
* Amounts are the raw input strings, parsed on use (same as customAmounts).
*/
/** Spread `amount` across `n` payers in whole cents so the parts sum back exactly. */
import { currencyDecimals } from '../../utils/formatters'
// The split and receipt fields guard their own precision on every keystroke, so the
// guard has to follow the currency: a three-decimal one (KWD, BHD, …) seeds three
// places, and the old fixed two rejected every keystroke after that, leaving the field
// unusable until a digit was deleted (#2175). Never stricter than two places, so no
// value a field accepts today can become uneditable.
export const amountPattern = (currency: string, signed: boolean) =>
new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${Math.max(2, currencyDecimals(currency))}}$`)
/**
* Spread `amount` across `n` payers in whole cents so the parts sum back exactly.
* Floor-based, so a negative total (a refund, #2176) splits just as exactly:
* the remainder is always in [0, n) and the parts still sum to the input.
*/
export function splitCents(amount: number, n: number): number[] {
if (n <= 0) return []
const cents = Math.max(0, Math.round(amount * 100))
const cents = Math.round(amount * 100)
const base = Math.floor(cents / n)
const rem = cents - base * n
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
@@ -58,6 +72,13 @@ export function rebalancePayers(
* The remainder cent rotates with the item id rather than always landing on the
* first member, so across several expenses the rounding evens out instead of
* always favouring the same person.
*
* Must stay share-for-share identical to the server's
* BudgetService.splitEqualShares (budget.service.ts) — the settlement is netted
* there, this copy only previews it. The remainder is `totalCents - baseCents*n`
* rather than `%` so a negative total (a refund, #2176) still yields a remainder
* in [0, n) and the shares sum back to the total exactly, like the server's do.
* The parity fixture in CostsPanel.helpers.test.ts pins both sides.
*/
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
const n = members.length
@@ -65,7 +86,7 @@ export function splitEqualShares(total: number, members: { user_id: number }[],
const totalCents = Math.round(total * 100)
const baseCents = Math.floor(totalCents / n)
const remainder = totalCents % n
const remainder = totalCents - baseCents * n
const shares: Record<number, number> = {}
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
@@ -295,6 +295,29 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
})
// #2225 follow-up — the ledger stopped charging payer-less expenses, but this
// tile kept counting them, so it contradicted the balances right beside it.
it('keeps a payer-less expense out of Your share, like the balances do', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] }
const noPayer = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 40, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, noPayer] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Taxi')
// "Your share" is the first of the two bold figures in the Total spend card's
// footer; the second is "You paid". 60 from the paid expense only — the 40
// nobody has paid for is not a share, though it still counts as spend.
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')!
const figures = [...card.querySelectorAll('b')].map(b => b.textContent ?? '')
expect(figures[0]).toContain('60')
expect(figures[0]).not.toContain('100')
})
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
let posted: Record<string, unknown> | null = null
@@ -518,8 +541,9 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(screen.getByTitle('Edit'))
// Loading used to be payers.find(...), which silently dropped the second payer.
// Seeded with two decimals since #2175, localized for EUR.
const amounts = await screen.findAllByTestId('payer-amount')
expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45', '45'])
expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45,00', '45,00'])
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
@@ -705,8 +729,9 @@ describe('CostsPanel — settlements in the ledger', () => {
await screen.findByText('Payment')
await user.click(screen.getByTitle('Edit'))
// The stored USD amount comes back as-is, not silently reread as euros.
expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30')
// The stored USD amount comes back as-is (padded to two decimals, #2175),
// not silently reread as euros.
expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30.00')
expect(screen.getByText(/^USD/)).toBeInTheDocument()
})
})
@@ -1300,8 +1325,9 @@ describe('CostsPanel — expense modal', () => {
await screen.findByText('Dinner')
await user.click(screen.getByTitle('Edit'))
expect((await screen.findByDisplayValue('70')).tagName).toBe('INPUT')
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
// Custom shares reopen padded to two decimals (#2175), localized for EUR.
expect((await screen.findByDisplayValue('70,00')).tagName).toBe('INPUT')
expect(screen.getByDisplayValue('30,00')).toBeInTheDocument()
// Excluding Bob drops his amount; the split no longer matches the total.
await user.click(screen.getByRole('button', { name: /bob/i }))
@@ -1430,7 +1456,8 @@ describe('CostsPanel — expense modal', () => {
)
expect(screen.getByDisplayValue('Hotel Astoria')).toBeInTheDocument()
expect(screen.getByDisplayValue('240')).toBeInTheDocument()
// The prefilled amount is padded to the base currency's decimals (#2175).
expect(screen.getByDisplayValue('240,00')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add expense' }))
await waitFor(() => expect(posted).toBeTruthy())
@@ -1640,7 +1667,8 @@ describe('CostsPanel — remaining paths', () => {
await screen.findByText('Mining rig')
await user.click(screen.getByTitle('Edit'))
expect(await screen.findByDisplayValue('2')).toBeInTheDocument()
// XBT is unknown to the locale map → dot separator, two decimals (#2175).
expect(await screen.findByDisplayValue('2.00')).toBeInTheDocument()
expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2)
})
@@ -1862,6 +1890,173 @@ describe('CostsPanel — mobile extras', () => {
})
})
// ── #2175: edit inputs are padded to the currency's decimals ────────────────
describe('CostsPanel — decimal padding on edit (#2175)', () => {
beforeEach(() => {
seedAlice()
clearExchangeRateCache()
})
afterEach(clearExchangeRateCache)
it('FE-W5COSTS-066: reopens 4,90 as "4,90" and 5,00 as "5,00", not "4,9" and "5"', async () => {
const user = userEvent.setup()
mount([
expense({ id: 301, name: 'Coffee', category: 'food', total_price: 4.9, payers: [{ user_id: 1, amount: 4.9 }], members: [{ user_id: 1, username: 'alice' }] }),
expense({ id: 302, name: 'Toll', category: 'transport', total_price: 5, payers: [{ user_id: 1, amount: 5 }], members: [{ user_id: 1, username: 'alice' }] }),
])
await screen.findByText('Coffee')
await user.click(screen.getAllByTitle('Edit')[0])
expect(await screen.findByDisplayValue('4,90')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await user.click(screen.getAllByTitle('Edit')[1])
expect(await screen.findByDisplayValue('5,00')).toBeInTheDocument()
})
it('FE-W5COSTS-071: a three-decimal currency seeds three places and the split field still takes input', async () => {
const user = userEvent.setup()
render(<ExpenseModal tripId={1} base="KWD" people={tripMembers} me={1} onClose={() => {}} onSaved={vi.fn()}
editing={expense({
id: 340, name: 'Museum', category: 'activities', currency: 'KWD', total_price: 3,
payers: [{ user_id: 1, amount: 3 }],
members: [{ user_id: 1, username: 'alice', amount: 1.5 }, { user_id: 2, username: 'bob', amount: 1.5 }],
})} />)
expect(await screen.findByDisplayValue('3.000')).toBeInTheDocument()
const shares = screen.getAllByDisplayValue('1.500') as HTMLInputElement[]
// The seed is three decimals, so a two-decimal guard would reject every further
// keystroke, including one typed in front of the decimal point.
await user.type(shares[0], '2', { initialSelectionStart: 0, initialSelectionEnd: 0 })
expect(shares[0].value).toBe('21.500')
// A fourth decimal is still not a fils.
await user.clear(shares[1])
await user.type(shares[1], '1.5005')
expect(shares[1].value).toBe('1.500')
})
})
// ── #2176: negative amounts record partial reimbursements ───────────────────
describe('CostsPanel — negative amounts (#2176)', () => {
beforeEach(() => {
seedAlice()
clearExchangeRateCache()
})
afterEach(clearExchangeRateCache)
it('FE-W5COSTS-067: "-100" can be typed and saves a negative expense with its payer', async () => {
const user = userEvent.setup()
let posted: Record<string, unknown> | null = null
server.use(http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel refund' }), id: 12 } })
}))
mount([])
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel refund')
// The reported symptom: the '-' was stripped on input, so this was untypeable.
await user.type(screen.getAllByPlaceholderText('0,00')[0], '-100')
const submits = screen.getAllByRole('button', { name: 'Add expense' })
expect(submits[submits.length - 1]).not.toBeDisabled()
await user.click(submits[submits.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(-100)
expect(posted!.payers).toEqual([{ user_id: 1, amount: -100 }])
expect(posted!.member_ids).toEqual([1, 2])
})
it('FE-W5COSTS-068: a refund reopens with its negative payer intact and saves it back', async () => {
const user = userEvent.setup()
let put: Record<string, unknown> | null = null
server.use(http.put('/api/trips/1/budget/310', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: dinner() })
}))
mount([expense({
id: 310, name: 'Hotel refund', category: 'lodging', total_price: -100,
payers: [{ user_id: 1, amount: -100 }],
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
})])
// The row shows the amount twice — total pill and the payer chip, which used
// to be filtered to positive amounts and left the refund's recipient invisible.
const row = (await screen.findByText('Hotel refund')).closest('.exp-row') as HTMLElement
expect(within(row).getAllByText(/100,00/).length).toBeGreaterThanOrEqual(2)
// Not "Unfinished": the refund has its recipient recorded.
expect(within(row).queryByText('Unfinished')).toBeNull()
await user.click(screen.getByTitle('Edit'))
// The negative payer reopens selected instead of resetting to "Nobody",
// and the total is seeded signed and padded.
expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument()
expect(screen.getByDisplayValue('-100,00')).toBeInTheDocument()
// Saving untouched must not silently drop the negative payer.
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put!.total_price).toBe(-100)
expect(put!.payers).toEqual([{ user_id: 1, amount: -100 }])
})
it('FE-W5COSTS-069: a payer-less refund is marked Unfinished like a payer-less bill', async () => {
mount([expense({ id: 320, name: 'Pending refund', category: 'misc', total_price: -50, payers: [], members: [{ user_id: 1, username: 'alice' }] })])
await screen.findByText('Pending refund')
expect(screen.getByText('Unfinished')).toBeInTheDocument()
})
it('FE-W5COSTS-070: the category breakdown nets refunds and keeps a net-negative category listed', async () => {
mount([
expense({ id: 330, name: 'Dinner', category: 'food', total_price: 90, payers: [{ user_id: 1, amount: 90 }], members: [{ user_id: 1, username: 'alice' }] }),
expense({ id: 331, name: 'Meal refund', category: 'food', total_price: -30, payers: [{ user_id: 1, amount: -30 }], members: [{ user_id: 1, username: 'alice' }] }),
expense({ id: 332, name: 'Cancelled tour', category: 'activities', total_price: -20, payers: [{ user_id: 1, amount: -20 }], members: [{ user_id: 1, username: 'alice' }] }),
])
await screen.findByText('Dinner')
const breakdown = screen.getByText('By category').parentElement as HTMLElement
// Food nets 90 − 30 = 60; the fully refunded tour keeps a row of its own.
expect(within(breakdown).getByText('60 €')).toBeInTheDocument()
expect(within(breakdown).getByText(/[-−]20\s*€/)).toBeInTheDocument()
})
it('FE-W5COSTS-072: the custom-split hint reads under and over the right way round on a refund', async () => {
const user = userEvent.setup()
render(<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} onClose={() => {}} onSaved={vi.fn()}
editing={expense({
id: 341, name: 'Hotel refund', category: 'accommodation', total_price: -100,
payers: [{ user_id: 1, amount: -100 }],
members: [{ user_id: 1, username: 'alice', amount: -70 }, { user_id: 2, username: 'bob' }],
})} />)
// -70 of -100: 30 still to hand out, not 30 too much.
expect(await screen.findByText('Sum of splits: €-70.00 of €-100.00 (under by €30.00)')).toBeInTheDocument()
await user.type(screen.getByPlaceholderText('-30,00'), '-40')
expect(screen.getByText('Sum of splits: €-110.00 of €-100.00 (over by €10.00)')).toBeInTheDocument()
})
it('FE-W5COSTS-073: the total can be turned into a refund without a minus key', async () => {
const user = userEvent.setup()
render(<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
onClose={() => {}} onSaved={vi.fn()} />)
const total = (await screen.findAllByPlaceholderText('0,00'))[0] as HTMLInputElement
await user.type(total, '100')
// The iOS decimal pad has no minus key, so the sign has to be reachable without one.
const toggle = screen.getByRole('button', { name: 'Switch between expense and refund' })
await user.click(toggle)
expect(total.value).toBe('-100')
expect(toggle).toHaveAttribute('aria-pressed', 'true')
})
})
describe('CostsPanel — split maths', () => {
it('FE-W5COSTS-039: an equal split with nobody to split with is empty', () => {
expect(splitEqualShares(90, [], 1)).toEqual({})
+49 -25
View File
@@ -10,14 +10,14 @@ import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, cleanAmount } from '../../utils/formatters'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
import { downloadBlob } from '../../utils/fileDownload'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { localToday } from '../Planner/today'
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import type { BudgetItem } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
@@ -123,7 +123,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// ── derived expense maths (everything converted to the base currency) ────
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in. A negative
// total (a refund, #2176) is just as unfinished until its recipient is named.
const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0
const myShareOf = (e: BudgetItem) => {
// Nobody paid, so nobody owes: the ledger skips these entirely (#2225), and
// counting them here left the tile contradicting the balances right beside it.
if (isUnfinished(e)) return 0
const myMember = (e.members || []).find(m => m.user_id === me)
if (!myMember) return 0
if (myMember.amount !== null && myMember.amount !== undefined) {
@@ -133,9 +140,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const myShare = shares[me] || 0
return convert(myShare, curOf(e))
}
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in.
const isUnfinished = (e: BudgetItem) => baseTotal(e) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0
const totals = useMemo(() => {
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
@@ -717,7 +721,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const c = catMeta(e.category)
const Icon = c.Icon
const cur = curOf(e)
const payers = (e.payers || []).filter(p => p.amount > 0)
const payers = (e.payers || []).filter(p => p.amount !== 0)
const net = round2(myPaidOf(e) - myShareOf(e))
const unfinished = isUnfinished(e)
const note = readUserNote(e)
@@ -878,9 +882,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
function CategoryBreakdown() {
// Categories net refunds against spend (#2176): a negative entry lowers its
// category's sum, and a category that nets negative keeps its own row —
// just without a bar, since the bars rank positive spend.
const tot: Record<string, number> = {}
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) !== 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('costs.noCategories')}</div>
// Bars are scaled relative to the most expensive category (the top row fills the
// bar), not to the trip grand total — makes the relative ranking readable.
@@ -888,7 +895,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(c => {
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
const v = tot[c.key]; const pct = maxCat > 0 && v > 0 ? v / maxCat * 100 : 0
return (
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
@@ -966,7 +973,9 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
const otherDefault = people.find(p => p.id !== me)?.id ?? me
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
// Seeded with the transfer's own currency decimals, so a reopened 4,90 reads
// "4.90" and not "4.9" (#2175) — and a JPY transfer gets no fake decimals.
const [amount, setAmount] = useState<string>(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '')
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
const [saving, setSaving] = useState(false)
@@ -1051,9 +1060,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || localToday())
const [note, setNote] = useState(() => readUserNote(editing))
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
// returns numbers, so a saved 4,90 would otherwise reopen as "4,9" and a saved
// 5,00 as "5". A prefill has no currency of its own — it is read as `base`,
// which is also what the currency field starts on.
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : ''
if (prefill?.amount != null) return String(prefill.amount)
if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : ''
if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
return ''
})
const [participants, setParticipants] = useState<Set<number>>(() =>
@@ -1064,8 +1077,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
// next". The single-payer dropdown stays the default path; multiPayer swaps in a
// per-person amount editor. 0 represents "Nobody (planning entry)"; on an
// existing expense a missing payer is a deliberate choice, so only a brand-new
// one defaults to me.
const initialPayers = (editing?.payers || []).filter(p => p.amount > 0)
// one defaults to me. A negative payer (the recipient of a refund, #2176) is a
// real payer — filtering on > 0 here would silently drop them on save.
const initialPayers = (editing?.payers || []).filter(p => p.amount !== 0)
const [payerId, setPayerId] = useState<number>(() => {
const existingPayer = initialPayers[0]
@@ -1076,7 +1090,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
for (const p of initialPayers) m[p.user_id] = String(p.amount)
for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency)
return m
})
// Payers the user typed an amount for: rebalance leaves these alone and makes
@@ -1101,7 +1115,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
if (editing && editing.members) {
for (const member of editing.members) {
if (member.amount !== null && member.amount !== undefined) {
m[member.user_id] = String(member.amount)
m[member.user_id] = amountToInputString(member.amount, currency)
}
}
}
@@ -1119,6 +1133,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
// How much is still to be handed out, read on the total's own side: on a refund
// (#2176) the shares run negative, so a plain total minus sum flips under and
// over around and sends the user the wrong way.
const splitShortfall = totalNum < 0 ? splitSum - totalNum : totalNum - splitSum
const each = participants.size > 0 ? totalNum / participants.size : 0
const equalShares = useMemo(() => {
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
@@ -1131,17 +1149,22 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const enteredSum = [...participants]
.filter(id => customAmounts[id])
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
const remaining = Math.max(0, totalNum - enteredSum)
// Clamped toward zero on the total's own side, so an over-entered positive
// split never suggests negative leftovers — while a negative total (#2176)
// still previews its negative equal shares.
const rest = totalNum - enteredSum
const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest)
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, customAmounts, editing])
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum))
// A negative total is a valid entry (a refund, #2176); only zero has nothing to say.
const valid = name.trim().length > 0 && payersOk && (
isTicketMode
? ticketValid
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
: totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
)
const onTotalChange = (v: string) => {
@@ -1196,7 +1219,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const handleCustomAmountChange = (id: number, val: string) => {
val = val.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
if (val === '' || amountPattern(currency, true).test(val)) {
setCustomAmounts(prev => ({ ...prev, [id]: val }))
}
}
@@ -1219,7 +1242,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const handleUpdateItemPrice = (id: string, price: string) => {
price = price.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
if (price === '' || amountPattern(currency, false).test(price)) {
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
}
}
@@ -1264,7 +1287,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const payerList = multiPayer
? [...payerIds]
.map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 }))
.filter(p => p.amount > 0)
.filter(p => p.amount !== 0)
: payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : []
// A receipt line can name somebody who is not ticked as a participant. Sending
// only the ticked set would drop their share, leaving the member sum short of
@@ -1334,8 +1357,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
onValueChange={onTotalChange}
signToggleLabel={t('costs.toggleSign')}
disabled={isTicketMode}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
</div>
@@ -1353,7 +1377,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
</div>
{currency !== base && totalNum > 0 && (
{currency !== base && totalNum !== 0 && (
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{formatMoney(totalNum, currency, locale)}</span>
<span className="text-content-faint">≈</span>
@@ -1422,7 +1446,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
onValueChange={v => onPayerAmountChange(p.id, v)}
className="text-content"
@@ -1591,7 +1615,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
{customBalanced
? t('costs.splitBalanced')
: t(totalNum - splitSum > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
: t(splitShortfall > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
sum: sym(currency) + splitSum.toFixed(2),
total: sym(currency) + totalNum.toFixed(2),
diff: sym(currency) + Math.abs(totalNum - splitSum).toFixed(2),
@@ -1275,7 +1275,7 @@ describe('CollabNotes', () => {
});
});
// FE-W5CNT-001 to FE-W5CNT-029
// FE-W5CNT-001 to FE-W5CNT-036
// Fills in the load/error/attachment/category branches of useCollabNotes and the
// view modal that the smoke tests above do not reach.
@@ -1327,6 +1327,14 @@ function wsHandler(): (msg: Record<string, unknown>) => void {
return (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
}
function openedNoteBody(): Promise<HTMLElement> {
return waitFor(() => {
const md = document.querySelector('.collab-note-md-full');
if (!md) throw new Error('view modal not open yet');
return md as HTMLElement;
});
}
describe('CollabNotes details', () => {
let addToast: ReturnType<typeof vi.fn<AddToast>>;
let filesChanged: number;
@@ -1912,4 +1920,59 @@ describe('CollabNotes details', () => {
// …and both rejected writes are one message, not one each.
expect(addToast).toHaveBeenCalledTimes(1);
});
it('FE-W5CNT-033: a placeholder in angle brackets survives sanitizing', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 7, title: 'Hotel', content: 'Zimmernummer <TBD> bestaetigen' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Hotel');
await user.click(screen.getByTitle('collab.notes.expand'));
const full = await openedNoteBody();
// Notes written before markdown rendering existed keep reading the way they
// were typed (#2177).
expect(within(full).getByText('Zimmernummer <TBD> bestaetigen')).toBeInTheDocument();
});
it('FE-W5CNT-034: a footnote reference lands on the footnote instead of a new tab', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 8, title: 'Booking', content: 'Hotel gebucht[^1]\n\n[^1]: Bestaetigung ABC123' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Booking');
await user.click(screen.getByTitle('collab.notes.expand'));
const full = await openedNoteBody();
const ref = within(full).getByRole('link', { name: '1' });
const href = ref.getAttribute('href')!;
expect(href.startsWith('#')).toBe(true);
expect(full.querySelector(`[id="${href.slice(1)}"]`)).not.toBeNull();
expect(ref).not.toHaveAttribute('target');
});
it('FE-W5CNT-035: the expanded note links its website out to a new tab', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 9, title: 'Hotel', website: 'www.hotel.test' })] });
server.use(http.get('/api/trips/1/collab/link-preview', () => HttpResponse.json({ title: 'Hotel', image: null })));
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Hotel');
await user.click(screen.getByTitle('collab.notes.expand'));
const full = await openedNoteBody();
// A member without collab_edit never opens the edit modal, so this is the
// only place the link is readable for them (#2222).
const link = within(full).getByRole('link');
expect(link).toHaveAttribute('href', 'https://www.hotel.test');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(link).toHaveTextContent('hotel.test');
});
it('FE-W5CNT-036: a javascript: website stays unlinked in the expanded note', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 10, title: 'Hotel', website: 'javascript:alert(1)' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Hotel');
await user.click(screen.getByTitle('collab.notes.expand'));
const full = await openedNoteBody();
expect(within(full).queryAllByRole('link')).toHaveLength(0);
});
});
+24 -2
View File
@@ -2,8 +2,9 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
import { createPortal } from 'react-dom'
import { Plus, Pencil, X, StickyNote, Settings } from 'lucide-react'
import { Plus, Pencil, X, StickyNote, Settings, ExternalLink } from 'lucide-react'
import { collabApi } from '../../api/client'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
@@ -20,6 +21,7 @@ import { CategorySettingsModal } from './CollabNotesCategorySettingsModal'
import { NoteCard } from './CollabNotesCard'
import { FilePreviewPortal } from './CollabNotesFilePreviewPortal'
import { AuthedImg } from './CollabNotesAuthedImg'
import { safeExternalHref } from '../../utils/safeUrl'
// ── Main Component ──────────────────────────────────────────────────────────
interface CollabNotesProps {
@@ -392,9 +394,18 @@ function CollabNotesGrid(S: NotesState) {
)
}
/** Label for the link chip: the host is what people recognise, "www." is noise. */
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
}
function ViewNoteModal(S: NotesState) {
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S
if (!viewingNote) return null
// A member without collab_edit only ever gets this modal, so the link has to
// be here too (#2222). Allow-listed like the card tile: the field takes any
// string, and a javascript: one would run in this origin.
const websiteHref = safeExternalHref(viewingNote.website)
return createPortal(
<div
style={{
@@ -445,7 +456,18 @@ function ViewNoteModal(S: NotesState) {
</div>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{viewingNote.content || ''}</Markdown>
{websiteHref && (
<a href={websiteHref} target="_blank" rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 12,
padding: '6px 12px', borderRadius: 999, border: '1px solid var(--border-primary)',
fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600,
color: 'var(--text-primary)', textDecoration: 'none',
}}>
<ExternalLink size={13} color="var(--text-muted)" /> {linkHost(websiteHref)}
</a>
)}
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
@@ -3,6 +3,7 @@ import { avatarSrc } from '../../utils/avatarSrc'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../shared/markdownSanitize'
import { Trash2, Pin, PinOff, Pencil, Maximize2 } from 'lucide-react'
import { FONT } from './CollabNotes.constants'
import { AuthedImg } from './CollabNotesAuthedImg'
@@ -142,7 +143,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT,
}}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{note.content}</Markdown>
</div>
)}
</div>
@@ -732,3 +732,146 @@ describe('CollabPolls details', () => {
}
});
});
// FE-COMP-POLLS-016 to FE-COMP-POLLS-022
// Markdown questions, sanitization and multiline input (#2177).
describe('CollabPolls markdown & multiline', () => {
it('FE-COMP-POLLS-016: a markdown question renders headings and bold text', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: '## Day trip\n\n**Vote** carefully' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
const heading = await screen.findByText('Day trip');
expect(heading.closest('h2')).not.toBeNull();
expect(screen.getByText('Vote').closest('strong')).not.toBeNull();
});
it('FE-COMP-POLLS-017: a script/onerror injection attempt produces no live DOM nodes', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({
polls: [buildPoll({ question: 'Safe title <script>alert("xss")</script> <img src=x onerror=alert(1)>' })],
}),
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/Safe title/);
expect(document.querySelector('script')).toBeNull();
expect(document.querySelector('img[src="x"]')).toBeNull();
expect(document.querySelector('[onerror]')).toBeNull();
});
it('FE-COMP-POLLS-018: blank lines split the question into separate paragraphs', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: 'First paragraph\n\nSecond paragraph' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
const first = await screen.findByText('First paragraph');
const second = screen.getByText('Second paragraph');
expect(first.tagName).toBe('P');
expect(second.tagName).toBe('P');
expect(first).not.toBe(second);
});
it('FE-COMP-POLLS-019: a plain-text question renders identically, without formatting nodes', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: 'Best destination?' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
const question = await screen.findByText('Best destination?');
expect(question.textContent).toBe('Best destination?');
// The question sits in a paragraph now, but no formatting was invented
expect(question.parentElement!.querySelector('strong, em, h1, h2, a')).toBeNull();
});
it('FE-COMP-POLLS-020: the question field is a textarea — Enter adds a line instead of submitting', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/collab/polls', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ poll: buildPoll({ id: 60, question: 'Line one\nLine two' }) });
}),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
const questionField = screen.getByPlaceholderText(/what should we do/i);
expect(questionField.tagName).toBe('TEXTAREA');
await user.type(questionField, 'Line one{enter}Line two');
// Enter stayed in the field — the form was not submitted without options
expect(body).toBeNull();
expect(questionField).toHaveValue('Line one\nLine two');
const optionInputs = screen.getAllByPlaceholderText(/option/i);
expect(optionInputs[0].tagName).toBe('TEXTAREA');
await user.type(optionInputs[0], 'A');
await user.type(optionInputs[1], 'B');
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
await waitFor(() => expect(body).toMatchObject({ question: 'Line one\nLine two' }));
});
it('FE-COMP-POLLS-021: a link in the question opens in a new tab with rel protection', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: 'Where? [Our hotel](https://example.com)' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
const link = await screen.findByRole('link', { name: 'Our hotel' });
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
});
it('FE-COMP-POLLS-022: a long multiline option wraps instead of being clipped', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({
polls: [buildPoll({
options: [
{ id: 1, text: 'Stay at the beach house\nwith the long unpronounceable name', voters: [] },
{ id: 2, text: 'Rome', voters: [] },
],
})],
}),
),
);
render(<CollabPolls {...defaultProps} />);
const label = (await screen.findByText(/Stay at the beach house/)).closest('span')!;
expect(label.style.whiteSpace).toBe('pre-wrap');
expect(label.style.overflowWrap).toBe('anywhere');
expect(label.style.minWidth).toBe('0px');
});
it('FE-COMP-POLLS-023: a placeholder in angle brackets survives sanitizing', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: 'Treffpunkt <noch offen> oder Hotel?' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
// Questions written before markdown rendering existed must keep reading the
// way they were typed, and they cannot be edited afterwards (#2177).
expect(await screen.findByText('Treffpunkt <noch offen> oder Hotel?')).toBeInTheDocument();
});
it('FE-COMP-POLLS-024: a script tag is shown as text, never as an element', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ question: 'Plan <script>alert("xss")</script>' })] }),
),
);
render(<CollabPolls {...defaultProps} />);
expect(await screen.findByText('Plan <script>alert("xss")</script>')).toBeInTheDocument();
expect(document.querySelector('script')).toBeNull();
});
});
+45 -7
View File
@@ -1,5 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
@@ -35,6 +39,34 @@ interface Poll {
const FONT = "var(--font-system)"
// Block styling for the markdown question (#2177). Tailwind's preflight resets
// headings/lists, so the sizes are set here — in em, on purpose: they scale with
// the surrounding calc(13px * var(--fs-scale-body)) base, so the user's
// text-size setting keeps working. Links and sanitizing come from
// markdownSanitize (#1629): new tab plus rel protection, this is cross-user
// content.
const pollMarkdownComponents: Components = {
...sanitizedMarkdownComponents,
p: ({ children }) => (
<p style={{ margin: '0 0 0.55em' }}>{children}</p>
),
h1: ({ children }) => (
<h1 style={{ margin: '0 0 0.45em', fontSize: '1.4em', lineHeight: 1.2 }}>{children}</h1>
),
h2: ({ children }) => (
<h2 style={{ margin: '0 0 0.45em', fontSize: '1.25em', lineHeight: 1.25 }}>{children}</h2>
),
h3: ({ children }) => (
<h3 style={{ margin: '0 0 0.45em', fontSize: '1.1em', lineHeight: 1.3 }}>{children}</h3>
),
ul: ({ children }) => (
<ul style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'disc' }}>{children}</ul>
),
ol: ({ children }) => (
<ol style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'decimal' }}>{children}</ol>
),
}
function timeRemaining(deadline) {
if (!deadline) return null
const diff = new Date(deadline).getTime() - Date.now()
@@ -98,7 +130,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
{/* Question */}
<div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
<textarea autoFocus rows={5} value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', minHeight: 110, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.4, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 3 }}>{t('collab.polls.markdownHint')}</div>
</div>
{/* Options */}
@@ -107,8 +140,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
<textarea rows={2} value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
style={{ flex: 1, minWidth: 0, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.35, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
{options.length > 2 && (
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
)}
@@ -223,7 +256,9 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
{poll.question}
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={pollMarkdownComponents}>
{poll.question}
</ReactMarkdown>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{isClosed && (
@@ -281,7 +316,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
disabled={isClosed}
style={{
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
position: 'relative', display: 'flex', alignItems: 'flex-start', gap: 8,
padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
overflow: 'hidden', transition: 'transform 0.1s',
@@ -308,9 +343,12 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
</div>
{/* Label */}
{/* Label — options stay plain text, but long or multiline ones
must wrap instead of being clipped by the button's
overflow:hidden (#2177) */}
<span style={{
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', wordBreak: 'break-word',
lineHeight: 1.35, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
}}>
{typeof opt === 'string' ? opt : opt.text}
@@ -1,4 +1,4 @@
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-010
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-011
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionPlace } from '@trek/shared';
@@ -157,4 +157,14 @@ describe('CollectionList', () => {
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toBeInTheDocument();
expect(screen.getByText('Golden Gate Bridge').closest('.col-lrow')).toBeInTheDocument();
});
it('FE-COMP-COLLIST-011: the row opts out of the global press-scale (#2158)', () => {
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
// the row-wide role="button" shifted the status badge out from under the pointer,
// so the click retargeted onto the row and opened the place instead of cycling the
// status. The data-no-press attribute is the pin.
renderList();
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toHaveAttribute('data-no-press');
});
});
@@ -44,6 +44,10 @@ export default function CollectionList({
key={place.id}
ref={active ? selectedRef : undefined}
role="button"
// Opt out of the global :active press-scale: shrinking the row-wide button
// slides the status badge out from under the pointer mid-click, so the click
// retargets onto the row and opens the place instead of cycling (#2158).
data-no-press
tabIndex={0}
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
@@ -189,6 +189,23 @@ describe('EntryCard', () => {
expect(await screen.findByRole('link', { name: 'https://book.example' })).toBeInTheDocument()
})
// #2064: a stop switched off the route says so on its card, so a journal
// that reads fine still shows which of its days the printed map skips.
it('FE-JRN-CARD-018: marks an entry left out of the route, on the header and on the hero', () => {
const { unmount } = mountCard(buildEntry({ stats_excluded: true }))
expect(screen.getByText('Off route')).toBeInTheDocument()
unmount()
mountCard(buildEntry({ stats_excluded: true, photos: [buildPhoto(100)] }))
expect(screen.getByText('Off route')).toBeInTheDocument()
})
it('FE-JRN-CARD-019: carries no such mark on an ordinary entry', () => {
mountCard(buildEntry({ photos: [buildPhoto(100)] }))
expect(screen.queryByText('Off route')).not.toBeInTheDocument()
})
})
describe('SkeletonCard', () => {
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus } from 'lucide-react'
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import { useTranslation } from '../../i18n'
import { pluginsApi } from '../../api/client'
@@ -68,6 +68,14 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{entry.entry_time}
</span>
)}
{/* Switched off the route (#2064): the day is still in the journal,
the printed map and the distance skip it, and the card says so. */}
{entry.stats_excluded && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide">
<RouteOff size={10} />
{t('journey.entry.offRoute')}
</span>
)}
</div>
{/* Menu top-right */}
@@ -110,6 +118,11 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
<Clock size={10} /> {entry.entry_time}
</span>
)}
{entry.stats_excluded && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500">
<RouteOff size={10} /> {t('journey.entry.offRoute')}
</span>
)}
</div>
{!readOnly && (
<div className="relative">
@@ -965,4 +965,67 @@ describe('EntryEditor', () => {
expect(library[0]).toHaveAttribute('multiple')
expect(camera[0]).not.toHaveAttribute('multiple')
})
// #2064: a home airport written up as an entry is a stop on the printed
// route. The editor offers the same switch the Studio panel has, but only
// to an entry that is (or was) a stop: one without a point never counted,
// and a new entry is not on the route yet.
it('FE-JRN-EDITOR-051: offers to leave a located entry out of the route and saves it', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({
id: 10, location_name: 'Keflavík', location_lat: 63.98, location_lng: -22.6,
}))
const toggle = screen.getByRole('button', { name: 'Leave out of the route' })
expect(toggle).toHaveAttribute('aria-pressed', 'false')
await user.click(toggle)
expect(toggle).toHaveAttribute('aria-pressed', 'true')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: true })
})
it('FE-JRN-EDITOR-052: keeps the switch away from a new entry, point or no point', () => {
mountEditor(buildEntry({ location_lat: 63.98, location_lng: -22.6 }))
expect(screen.queryByRole('button', { name: 'Leave out of the route' })).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-053: keeps the switch away from an entry without a point, and sends nothing for it', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 10 }))
expect(screen.queryByRole('button', { name: 'Leave out of the route' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0].stats_excluded).toBeUndefined()
})
it('FE-JRN-EDITOR-054: still offers the switch to a left-out entry, so it can be put back', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 10, stats_excluded: true }))
const toggle = screen.getByRole('button', { name: 'Leave out of the route' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: false })
})
it('FE-JRN-EDITOR-055: flipping the switch is a change worth warning about', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
const user = userEvent.setup()
const { onClose } = mountEditor(buildEntry({ id: 10, location_lat: 63.98, location_lng: -22.6 }))
await user.click(screen.getByRole('button', { name: 'Leave out of the route' }))
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(confirmSpy).toHaveBeenCalledWith('You have unsaved changes. Discard them?')
expect(onClose).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
})
@@ -14,6 +14,7 @@ import { photoUrl, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyD
import MarkdownToolbar from './MarkdownToolbar'
import { DatePicker } from './JourneyDetailPageDatePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
@@ -48,6 +49,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [mood, setMood] = useState(entry.mood || '')
const [weather, setWeather] = useState(entry.weather || '')
const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false)
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false)
@@ -87,6 +89,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
(locationLng ?? null) !== (entry.location_lng ?? null) ||
mood !== (entry.mood || '') ||
weather !== (entry.weather || '') ||
statsExcluded !== (entry.stats_excluded ?? false) ||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
pendingFiles.length > 0 ||
@@ -146,6 +149,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
location_name: locationName || null,
location_lat: locationLat,
location_lng: locationLng,
stats_excluded: offersStatsToggle ? statsExcluded : undefined,
mood: mood || null,
weather: weather || null,
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
@@ -214,6 +218,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
? { lat: locationLat!, lng: locationLng!, name: locationName || undefined }
: null
// The route switch belongs to an entry that is a stop, or was one: an entry
// without a point was never on the route, and a new one is not on it yet.
const offersStatsToggle = entry.id > 0 && (contextLocation != null || !!entry.stats_excluded)
const handleUseCurrentLocation = async () => {
if (locating) return
setLocating(true)
@@ -695,6 +703,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
</div>
{/* Every located entry is a stop on the route Studio prints, the home
airport included. This is the entry's own way off it, the same
switch the Studio travel panel offers (#2064). */}
{offersStatsToggle && (
<div className="flex items-center gap-3 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl">
<div className="flex-1 min-w-0">
<div className="text-[12px] font-semibold text-zinc-900 dark:text-white">{t('journey.editor.statsExcluded')}</div>
<div className="text-[11px] leading-snug text-zinc-500 mt-0.5">{t('journey.editor.statsExcludedHint')}</div>
</div>
<ToggleSwitch on={statsExcluded} onToggle={() => setStatsExcluded(v => !v)} label={t('journey.editor.statsExcluded')} />
</div>
)}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
<div className="flex flex-wrap gap-2">
@@ -1,7 +1,7 @@
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-019
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { act } from 'react'
import { act, useRef, useState } from 'react'
import { http, HttpResponse } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
@@ -73,20 +73,41 @@ const entries: JourneyEntry[] = [
},
]
/** Mirrors the page wiring: the gallery reports its connected providers up,
and the host renders one button per provider next to Upload. */
function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload }: {
gallery: GalleryPhoto[]
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void
onRegisterUpload?: (fn: () => void) => void
}) {
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
const browseRef = useRef<((provider: string) => void) | null>(null)
return (
<>
{providers.map(p => (
<button type="button" key={p.id} onClick={() => browseRef.current?.(p.id)}>{p.name}</button>
))}
<GalleryView
entries={entries}
gallery={gallery}
journeyId={JOURNEY_ID}
userId={1}
trips={trips}
onPhotoClick={onPhotoClick}
onRefresh={onRefresh}
onRegisterUpload={onRegisterUpload}
onRegisterProviders={(p, browse) => { setProviders(p); browseRef.current = browse }}
/>
</>
)
}
function mountGallery(gallery: GalleryPhoto[], onRegisterUpload?: (fn: () => void) => void) {
const onPhotoClick = vi.fn()
const onRefresh = vi.fn()
const utils = render(
<GalleryView
entries={entries}
gallery={gallery}
journeyId={JOURNEY_ID}
userId={1}
trips={trips}
onPhotoClick={onPhotoClick}
onRefresh={onRefresh}
onRegisterUpload={onRegisterUpload}
/>,
<GalleryHarness gallery={gallery} onPhotoClick={onPhotoClick} onRefresh={onRefresh} onRegisterUpload={onRegisterUpload} />,
)
return { ...utils, onPhotoClick, onRefresh }
}
@@ -352,4 +373,14 @@ describe('GalleryView', () => {
expect(container.querySelectorAll('.aspect-square')).toHaveLength(2)
expect(container.querySelector('.animate-spin')).toBeNull()
})
it('FE-JRN-GALLERY-019: the tile opts out of the global press-scale (#2158)', () => {
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
// the tile shifted its corner delete button out from under the pointer, so the
// click retargeted onto the tile and opened the lightbox instead of deleting.
// The data-no-press attribute is the pin.
const { container } = mountGallery([buildGalleryPhoto()])
expect(container.querySelector('.aspect-square')).toHaveAttribute('data-no-press')
})
})
@@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'
import { RefreshCw, Camera, Image, X, Play } from 'lucide-react'
import { RefreshCw, Camera, X, Play } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { useJourneyStore } from '../../store/journeyStore'
@@ -13,7 +13,7 @@ import { ProviderPicker } from './JourneyDetailPageProviderPicker'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState'
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: {
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload, onRegisterProviders }: {
entries: JourneyEntry[]
gallery: GalleryPhoto[]
journeyId: number
@@ -22,16 +22,17 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void
onRegisterUpload?: (fn: () => void) => void
onRegisterProviders?: (providers: { id: string; name: string }[], browse: (provider: string) => void) => void
}) {
const { t } = useTranslation()
const [showPicker, setShowPicker] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
const galleryUploading = galleryProgress !== null
const toast = useToast()
// check which providers are enabled AND connected for the current user
// check which providers are enabled AND connected for the current user, then
// hand them up: the page header renders the provider buttons next to Upload
useEffect(() => {
(async () => {
try {
@@ -46,7 +47,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
if (status.connected) connected.push({ id: p.id, name: p.name })
} catch { }
}
setAvailableProviders(connected)
onRegisterProviders?.(connected, browseProvider)
} catch { }
})()
}, [])
@@ -126,23 +127,11 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
<div>
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple onChange={handleGalleryUpload} className="hidden" />
{/* Header */}
{/* Header — the provider buttons live in the page header next to Upload */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}>
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos')}
</span>
<div className="flex items-center gap-2">
{availableProviders.map(p => (
<button type="button"
key={p.id}
onClick={() => browseProvider(p.id)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[11px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<Image size={12} />
{p.name}
</button>
))}
</div>
</div>
{allPhotos.length === 0 ? (
@@ -153,6 +142,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
<div
key={photo.id}
role="button"
// No press-scale on the tile: shrinking it mid-click slides the corner
// delete button out from under the pointer, so the click retargets onto
// the tile and opens the lightbox instead (#2158).
data-no-press
tabIndex={0}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos, i)}
@@ -373,4 +373,40 @@ describe('JourneySettingsDialog', () => {
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'Journey Settings' })).toBeInTheDocument()
})
it('FE-JRN-SETTINGS-023: the trip-GPX switch reads off by default and turns on in one click (#2194)', async () => {
const { onRefresh, onSaved } = mountDialog()
const sw = screen.getByRole('switch', { name: /GPX/i })
expect(sw).toHaveAttribute('aria-checked', 'false')
await userEvent.click(sw)
await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: true }))
// Refresh, never onSaved: onSaved closes the dialog, which would destroy the
// switch as it is flipped and skip the unsaved-changes guard.
await waitFor(() => expect(onRefresh).toHaveBeenCalled())
expect(onSaved).not.toHaveBeenCalled()
})
it('FE-JRN-SETTINGS-024: a journey that already draws its tracks switches them back off (#2194)', async () => {
// The column is INTEGER, so what arrives over the wire is 1, not true.
mountDialog(buildJourney({ show_trip_tracks: 1 }))
const sw = screen.getByRole('switch', { name: /GPX/i })
expect(sw).toHaveAttribute('aria-checked', 'true')
await userEvent.click(sw)
await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: false }))
})
it('FE-JRN-SETTINGS-025: a failed switch write says so and leaves the dialog open (#2194)', async () => {
updateJourney.mockRejectedValueOnce(new Error('nope'))
const { onClose } = mountDialog()
await userEvent.click(screen.getByRole('switch', { name: /GPX/i }))
await waitFor(() => expect(toastSpy).toHaveBeenCalled())
expect(onClose).not.toHaveBeenCalled()
})
})
@@ -77,6 +77,24 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
}
}
// Saved on the spot rather than on Save, like the archive switch above it:
// it is a view setting, and the point of it is seeing the map change (#2194).
const [savingTracks, setSavingTracks] = useState(false)
const handleTracksToggle = async () => {
setSavingTracks(true)
try {
await updateJourney(journey.id, { show_trip_tracks: !journey.show_trip_tracks })
// onRefresh, not onSaved: onSaved closes the dialog, which would destroy the
// switch the moment it is flipped AND skip handleClose's unsaved-changes
// guard, silently dropping a title the owner had typed but not saved yet.
onRefresh()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSavingTracks(false)
}
}
const handleDelete = async () => {
try {
await deleteJourney(journey.id)
@@ -141,6 +159,30 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
/>
</div>
{/* Trip GPX tracks on the journey map (#2194) */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.tracks')}</label>
<button
type="button"
role="switch"
aria-checked={!!journey.show_trip_tracks}
disabled={savingTracks}
onClick={handleTracksToggle}
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left disabled:opacity-60"
>
<span className="flex-1 min-w-0">
<span className="block text-[14px] text-zinc-900 dark:text-white">{t('journey.settings.showTripTracks')}</span>
<span className="block text-[11px] text-zinc-500">{t('journey.settings.showTripTracksHint')}</span>
</span>
<span
aria-hidden="true"
className={`w-9 h-5 rounded-full flex-shrink-0 p-0.5 transition-colors ${journey.show_trip_tracks ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-600'}`}
>
<span className={`block w-4 h-4 rounded-full bg-white transition-transform ${journey.show_trip_tracks ? 'translate-x-4' : ''}`} />
</span>
</button>
</div>
</div>
{/* Right column */}
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-027
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-050
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -27,6 +27,10 @@ vi.mock('leaflet', () => {
setView: vi.fn(),
flyTo: vi.fn(),
getZoom: vi.fn(() => 10),
// Leaflet throws "Set map center and zoom first." out of these until the map
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
// takes it away for the window that #2254 fell into.
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
// The photo layer subscribes to zoom/pan and clusters in screen space (#1614).
@@ -58,6 +62,7 @@ import { render, act, fireEvent, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { buildSettings } from '../../../tests/helpers/factories';
import type { Mock } from 'vitest';
import L from 'leaflet';
import JourneyMap from './JourneyMap';
import type { JourneyMapHandle } from './JourneyMap';
@@ -604,6 +609,39 @@ describe('JourneyMap', () => {
expect(colors).not.toContain('#ffffff');
});
/** The opening viewport is the assertion here, so the frame gets read out of the bounds call. */
const fitCoords = () => vi.mocked(L.latLngBounds).mock.calls[0][0] as [number, number][];
// #2194: a recorded drive across the country used to pull the opening view out to
// the whole trip, leaving the journey's own entries as a speck.
it('FE-COMP-JOURNEYMAP-048: frames the entries, not the tracks, when the journey has both', () => {
const origRAF = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
try {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ points: [[10, 10], [11, 11]] })]} />
);
expect(mockedMap().fitBounds).toHaveBeenCalled();
expect(fitCoords()).toEqual([[48.8566, 2.3522], [52.52, 13.405]]);
} finally {
globalThis.requestAnimationFrame = origRAF;
}
});
it('FE-COMP-JOURNEYMAP-049: with nothing else on the map the tracks are framed instead of the world', () => {
const origRAF = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
try {
render(<JourneyMap checkins={[]} entries={entriesWithoutCoords} tracks={[track()]} />);
expect(fitCoords()).toEqual([[47.1, 11.2], [47.2, 11.3]]);
expect(mockedMap().setView).not.toHaveBeenCalledWith([30, 0], 2);
} finally {
globalThis.requestAnimationFrame = origRAF;
}
});
// A string handed to bindTooltip becomes innerHTML. A track name is a place
// name off a shared trip and an entry label is a journey title, so both are
// written by someone who is not necessarily the person reading the map — and
@@ -660,4 +698,47 @@ describe('JourneyMap', () => {
expect(html).toHaveLength(1);
expect(html[0]).not.toMatch(/>\d+</);
});
// #2254 — the map effect defers its opening fitBounds/setView to a rAF, so this
// effect can draw while the map still has no center or zoom. Clustering
// projects to container pixels, which throws in that window and took the whole
// public journey page down with it.
it('FE-COMP-JOURNEYMAP-050: waits out a map that has no view yet, then draws on moveend', () => {
const noView = () => { throw new Error('Set map center and zoom first.'); };
let hasView = false;
const photoHtml = () =>
vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
// The mock hands out one shared map instance, so it can be reached — and
// stripped of its view — before the component ever builds one. Typed down to
// the three spies this needs: L.map's signature promises a real Leaflet map.
type MapProbe = { getCenter: Mock; latLngToContainerPoint: Mock; on: Mock };
L.map(document.createElement('div'));
const map = mockedMap() as MapProbe;
vi.mocked(L.map).mockClear();
map.getCenter.mockImplementation(() => (hasView ? { lat: 0, lng: 0 } : noView()));
map.latLngToContainerPoint.mockImplementation(() => (hasView ? { x: 0, y: 0 } : noView()));
try {
// Rendering must survive the viewless window, and draw nothing in it.
expect(() =>
render(
<JourneyMap checkins={[]} entries={[]} photos={[{ id: 'p1', lat: 1, lng: 2, thumbUrl: '/uploads/late.jpg' }]} />,
),
).not.toThrow();
expect(photoHtml()).toHaveLength(0);
// The deferred fitBounds lands: the listener this effect registered redraws.
hasView = true;
const redraw = map.on.mock.calls.find(c => c[0] === 'moveend')?.[1] as () => void;
expect(redraw).toBeTypeOf('function');
act(() => { redraw(); });
expect(photoHtml()).toHaveLength(1);
expect(photoHtml()[0]).toContain('/uploads/late.jpg');
} finally {
map.getCenter.mockImplementation(() => ({ lat: 0, lng: 0 }));
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
}
});
});
+33 -3
View File
@@ -268,6 +268,23 @@ function JourneyMap(
itemsRef.current = items
const allCoords: L.LatLngTuple[] = []
/**
* Track geometry is kept OUT of the fit set (#2194) and used only when there
* is nothing else to frame.
*
* A journey's opening viewport should show the journey — its entries and the
* trail between them. Letting a recorded GPX into the bounds meant one drive
* across a country zoomed the map out until the entries were specks, which
* is what the reporter's screenshot shows. JourneyMapGL already fits on
* entries + trail alone, so for every journey that has entries the two
* renderers now agree where they used to differ.
*
* They still differ for a journey with tracks and nothing else: this one
* frames the tracks, JourneyMapGL falls back to the world view. Framing the
* only thing on the map is the better of the two, and converging the GL side
* is a change to a renderer this issue is not about.
*/
const trackCoords: L.LatLngTuple[] = []
if (stableTrail.length > 1) {
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
@@ -293,7 +310,7 @@ function JourneyMap(
// becomes innerHTML, and a track name is a place name off a shared trip.
if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' })
line.addTo(map)
coords.forEach(c => allCoords.push(c))
coords.forEach(c => trackCoords.push(c))
}
// route polyline — only in non-fullscreen (sidebar map) mode
@@ -341,9 +358,12 @@ function JourneyMap(
if (!mapRef.current) return
try {
map.invalidateSize()
if (allCoords.length > 0) {
// Tracks only get a say when the journey has nothing located of its own —
// a world view would be worse than framing the one thing on the map.
const fitCoords = allCoords.length > 0 ? allCoords : trackCoords
if (fitCoords.length > 0) {
const pb = paddingBottom || 50
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
map.fitBounds(L.latLngBounds(fitCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
} else {
map.setView([30, 0], 2)
}
@@ -385,6 +405,16 @@ function JourneyMap(
photoLayerRef.current = null
if (!photos?.length) return
// The initial view (setView/fitBounds) is set on a deferred rAF in the
// map-build effect above, so this can run before the map has a center/zoom
// — latLngToContainerPoint throws in that window. Skip; the moveend/zoomend
// listener below redraws once the deferred view lands.
try {
map.getCenter()
} catch {
return
}
const group = L.layerGroup()
for (const cluster of clusterPhotos(map, photos)) {
const marker = L.marker([cluster.lat, cluster.lng], {
@@ -0,0 +1,76 @@
// FE-MAP-LOCBTN-001 to FE-MAP-LOCBTN-005
import { render } from '@testing-library/react';
import LocationButton from './LocationButton';
import type { GeoWatchErrorCode } from '../../hooks/useGeolocation';
// useToast talks to the mounted ToastContainer through window.__addToast,
// so a spy there observes exactly what the user would get shown. Outside a
// TranslationProvider t(key) echoes the key, so assertions target the keys.
const addToast = vi.fn();
beforeEach(() => {
addToast.mockClear();
window.__addToast = addToast;
});
afterEach(() => {
delete window.__addToast;
});
const noop = () => {};
function button(errorCode: GeoWatchErrorCode | null, error: string | null = errorCode && 'raw webkit text') {
return <LocationButton mode="off" error={error} errorCode={errorCode} onClick={noop} />;
}
describe('LocationButton', () => {
it('FE-MAP-LOCBTN-001: shows the mode title and stays quiet without an error', () => {
const { getByRole } = render(button(null));
expect(getByRole('button').getAttribute('title')).toBe('Show my location');
expect(addToast).not.toHaveBeenCalled();
});
it('FE-MAP-LOCBTN-002: titles with the localized error text, not the raw browser string', () => {
const { getByRole } = render(button('timeout'));
expect(getByRole('button').getAttribute('title')).toBe('map.location.timeout');
expect(getByRole('button').getAttribute('aria-label')).toBe('map.location.timeout');
});
it('FE-MAP-LOCBTN-003: fires exactly one error toast when a code appears', () => {
const { rerender } = render(button(null));
expect(addToast).not.toHaveBeenCalled();
rerender(button('permission-denied'));
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith('map.location.denied', 'error', 6000);
// The same error sticking around must not re-announce itself.
rerender(button('permission-denied'));
expect(addToast).toHaveBeenCalledTimes(1);
});
it('FE-MAP-LOCBTN-004: only the transition from no error to an error toasts', () => {
const { rerender } = render(button(null));
rerender(button('permission-denied'));
expect(addToast).toHaveBeenCalledTimes(1);
// Code to code without recovering in between stays silent.
rerender(button('timeout'));
expect(addToast).toHaveBeenCalledTimes(1);
// After a recovery a fresh failure announces itself again.
rerender(button(null));
rerender(button('timeout'));
expect(addToast).toHaveBeenCalledTimes(2);
expect(addToast).toHaveBeenLastCalledWith('map.location.timeout', 'error', 6000);
});
it('FE-MAP-LOCBTN-005: an insecure origin gets the HTTPS hint, not the permission one', () => {
const { getByRole } = render(button('insecure-context', 'Only secure origins are allowed'));
expect(addToast).toHaveBeenCalledWith('journey.editor.locationInsecureContext', 'error', 6000);
expect(getByRole('button').getAttribute('title')).toBe('journey.editor.locationInsecureContext');
});
});
+38 -4
View File
@@ -1,24 +1,58 @@
import { useEffect, useRef } from 'react'
import { Navigation, LocateFixed, Locate } from 'lucide-react'
import type { TrackingMode } from '../../hooks/useGeolocation'
import type { TrackingMode, GeoWatchErrorCode } from '../../hooks/useGeolocation'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
interface Props {
mode: TrackingMode
error: string | null
errorCode: GeoWatchErrorCode | null
onClick: () => void
// Offset from the bottom edge — callers push this up above the mobile
// bottom nav. Defaults to 20px for desktop.
bottomOffset?: number
}
// The raw error string from the browser is unlocalized WebKit English, so the
// user-facing message is looked up from the typed code instead. 'unsupported'
// shares the generic text: a browser without geolocation cannot locate you.
// 'insecure-context' borrows the journal editor's wording, which already says
// exactly this in all locales.
const ERROR_KEYS: Record<GeoWatchErrorCode, string> = {
'permission-denied': 'map.location.denied',
'unavailable': 'map.location.unavailable',
'timeout': 'map.location.timeout',
'unsupported': 'map.location.unavailable',
'insecure-context': 'journey.editor.locationInsecureContext',
}
// Three-state FAB. Matches the Apple/Google Maps pattern:
// off → outline locate icon
// show → filled locate (blue dot is visible on the map)
// follow → filled navigation arrow (map follows + rotates with heading)
export default function LocationButton({ mode, error, onClick, bottomOffset = 20 }: Props) {
export default function LocationButton({ mode, error, errorCode, onClick, bottomOffset = 20 }: Props) {
const { t } = useTranslation()
const toast = useToast()
// A title tooltip is invisible on touch, which is exactly where geolocation
// errors happen (iOS PWA, see discussion #2095), so surface new errors as a
// toast. The ref guards against re-fires: only the transition from no error
// to an error announces itself.
const prevCodeRef = useRef<GeoWatchErrorCode | null>(null)
useEffect(() => {
const prev = prevCodeRef.current
prevCodeRef.current = errorCode
if (errorCode && prev === null) {
// The denied message is long, so give the toast extra time to be read.
toast.error(t(ERROR_KEYS[errorCode]), 6000)
}
}, [errorCode, t, toast])
const Icon = mode === 'follow' ? Navigation : mode === 'show' ? LocateFixed : Locate
const isActive = mode !== 'off'
const title = error
? error
const errorText = errorCode ? t(ERROR_KEYS[errorCode]) : error
const title = errorText
? errorText
: mode === 'off'
? 'Show my location'
: mode === 'show'
@@ -36,6 +36,7 @@ const geoMock = vi.hoisted(() => ({
position: null as { lat: number; lng: number; accuracy: number; heading: number | null } | null,
mode: 'off' as 'off' | 'show' | 'follow',
error: null as string | null,
errorCode: null as string | null,
cycleMode: vi.fn(),
setMode: vi.fn(),
}))
+227 -25
View File
@@ -3,7 +3,34 @@ import DOM from 'react-dom'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
/**
* The drive-time pill for one offered route.
*
* A divIcon rather than a tooltip: it has to be clickable, it has to sit exactly on the
* road, and it is the same shape the GL renderers build by hand — one look across all
* three maps.
*/
function alternativeLabelIcon(label: string, note: string, background: string, active: boolean) {
const outline = active ? 'outline:2px solid #fff;outline-offset:1px;' : ''
const second = note
? `<span style="font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2">${escapeHtml(note)}</span>`
: ''
return L.divIcon({
className: '',
// `font-family` spelled out: a divIcon sits inside `.leaflet-container`, whose own
// stylesheet sets Helvetica/Arial on everything in it. Without this the label is the
// one piece of TREK chrome on the map that is not in the app's typeface.
html: `<span style="display:inline-flex;flex-direction:column;align-items:flex-start;white-space:nowrap;background:${background};color:${ALT_LABEL_TEXT};border-radius:11px;padding:6px 11px;font-family:var(--font-system);font-size:13px;font-weight:600;line-height:1.25;box-shadow:0 2px 10px rgba(0,0,0,.35);cursor:pointer;transition:none;${outline}"><span>${escapeHtml(label)}</span>${second}</span>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
})
}
import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
@@ -76,9 +103,21 @@ function formatViaDwell(seconds: number): string {
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumbers, isSelected) {
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}`
const cached = iconCache.get(cacheKey)
if (cached) return cached
// A stop that interrupts the drive is drawn as its own small disc, before the photo
// branch below ever gets a look at it — the brand logo a fuel search comes back with
// is exactly what this replaces. No number badge either, for the same reason the rail
// gives it none: it is part of the drive, not one of the day's stops.
const service = serviceMarkerHtml(place.stop_type, isSelected)
if (service) {
const outer = serviceMarkerOuter(isSelected)
const icon = L.divIcon({ className: '', html: service, iconSize: [outer, outer], iconAnchor: [outer / 2, outer / 2] })
iconCache.set(cacheKey, icon)
return icon
}
const size = isSelected ? 44 : 36
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
// escaping stops the attribute breakout but still permits a CSS url().
@@ -159,7 +198,13 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
// markers of planned places; the colour matches its pill category.
const poiIconCache = new Map<string, L.DivIcon>()
function createPoiIcon(category: string) {
function createPoiIcon(category: string, brandWikidata?: string | null) {
// One flat disc in the category's colour with its icon, and never the chain's logo.
// The brands turned a corridor full of petrol stations into a row of advertisements,
// they were unreadable at pin size, and a brand with no logo on file fell back to a
// different picture entirely — so no two pins looked alike. `brandWikidata` is kept in
// the signature because the callers still have it; it simply no longer changes anything.
void brandWikidata
const cached = poiIconCache.get(category)
if (cached) return cached
const cat = POI_CATEGORY_BY_KEY[category]
@@ -167,7 +212,7 @@ function createPoiIcon(category: string) {
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
const icon = L.divIcon({
className: '',
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
tooltipAnchor: [0, -14],
@@ -193,6 +238,41 @@ function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: bo
return null
}
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
const map = useMap()
useEffect(() => {
if (!onPoiDropOnRoute) return
const container = map.getContainer()
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [map, onPoiDropOnRoute])
return null
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area.
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
@@ -277,9 +357,17 @@ interface BoundsControllerProps {
paddingOpts: L.FitBoundsOptions
/** The map was built already framed on these places, so the opening fit has nothing to do. */
framedOnMount?: boolean
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
* for itself that it means "the selected day". Weighing the ways of driving one leg
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false }: BoundsControllerProps) {
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
@@ -336,6 +424,15 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
if (!focusPoints?.length) return
// A day fit that has not run yet must not overwrite this a moment later.
awaitingRoute.current = false
fitTo(focusPoints)
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
@@ -538,6 +635,18 @@ export const MapView = memo(function MapView({
onViewportChange,
tripId,
routeVias = [],
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
roadtripVias,
onMoveVia,
onRemoveVia,
alternativeRoutes,
focusPoints,
clusterLoosely = false,
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
}: any) {
// The caller hands over whatever the user configured; what kind of basemap
// that is decides which layer draws it. A saved raster template still wins,
@@ -547,13 +656,21 @@ export const MapView = memo(function MapView({
<Marker
key={`poi-${poi.osm_id}`}
position={[poi.lat, poi.lng]}
icon={createPoiIcon(poi.category)}
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
zIndexOffset={500}
eventHandlers={{ click: () => onPoiClick?.(poi) }}
eventHandlers={{
click: () => onPoiClick?.(poi),
// Wired on add rather than in a layout effect: Leaflet builds the icon element
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
const el = e.target.getElement()
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
},
}}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
</Marker>
)), [pois, onPoiClick])
)), [pois, onPoiClick, onPoiDropOnRoute])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
@@ -802,7 +919,7 @@ export const MapView = memo(function MapView({
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation()
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
@@ -872,43 +989,127 @@ export const MapView = memo(function MapView({
)}
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
<ViewportController onViewportChange={onViewportChange} />
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
zoomed right out, and a day's stops merging into one dot is exactly the shape
it is being looked at for. Rendered outside the group rather than with the
clustering disabled by option, so nothing is left to a plugin's own idea of
what "disabled" means. */}
{clusterLoosely ? markers : (
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
)}
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
<Polyline
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu
// underneath the new via.
e.originalEvent.stopPropagation()
onRouteClick(e.latlng.lat, e.latlng.lng)
},
} : undefined}
/>,
<Polyline
key={`${i}-core`}
positions={seg}
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [])}
{/* The last bit to a place the road does not reach.
Dashed and thin, over the route rather than under it, because it is the one
piece of the line that is not driving: the router snapped the stop to the
nearest road and the drive really ends there. Same blue as the route, so it
reads as the end of that route and not as a second one. */}
{(accessLines ?? []).map((spur, i) => (
<Polyline
key={`access-${i}`}
positions={spur.line}
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
interactive={false}
/>
))}
{/* The offered ways of driving one leg, over the route they replace. Clicking one
takes it, which is the same choice the bar above the map offers. */}
{/* Every option in blue over a white casing, after Apple Maps: they are all real
roads, so the difference is emphasis, not category. Grey was the first attempt
and it disappeared on Positron, which is almost entirely greys. */}
{(alternativeRoutes ?? []).flatMap((alt: AlternativeOverlay) => {
const active = activeAlternative === alt.index
return [
<Polyline
key={`alt-${alt.index}-casing`}
positions={alt.coordinates}
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
<Polyline
key={`alt-${alt.index}`}
positions={alt.coordinates}
pathOptions={{
color: alt.color,
weight: active ? 6 : 4,
opacity: active ? 1 : 0.9,
lineCap: 'round',
lineJoin: 'round',
}}
eventHandlers={onChooseAlternative ? {
click: (e: { originalEvent: MouseEvent }) => { e.originalEvent.stopPropagation(); onChooseAlternative(alt.index) },
mouseover: () => onHighlightAlternative?.(alt.index),
mouseout: () => onHighlightAlternative?.(null),
} : undefined}
/>,
// The drive time on the road itself, the way Apple labels them.
<Marker
key={`alt-${alt.index}-label`}
position={[alt.at.lat, alt.at.lng]}
zIndexOffset={600}
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
eventHandlers={onChooseAlternative ? {
click: () => onChooseAlternative(alt.index),
mouseover: () => onHighlightAlternative?.(alt.index),
mouseout: () => onHighlightAlternative?.(null),
} : undefined}
/>,
]
})}
{/* The handles that shape the drive. After the route so they sit on top of it. */}
{roadtripVias ? (
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
) : null}
{/* GPX imported route geometries */}
<TrackCasingPane onReady={setHasCasingPane} />
{gpxPolylines}
@@ -941,6 +1142,7 @@ export const MapView = memo(function MapView({
{isMobile && <LocationButton
mode={trackingMode}
error={trackingError}
errorCode={trackingErrorCode}
onClick={cycleTrackingMode}
bottomOffset={locationButtonBottom as unknown as number}
/>}
@@ -195,6 +195,7 @@ const geoStub = vi.hoisted(() => ({
position: null as GeoPosition | null,
mode: 'off' as TrackingMode,
error: null as string | null,
errorCode: null as string | null,
cycleMode: vi.fn(async () => {}),
setMode: vi.fn((_m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {}),
}))
@@ -1854,4 +1855,104 @@ describe('MapViewGL', () => {
expect(glMap.off).toHaveBeenCalledWith('moveend', expect.any(Function))
expect(glMap.off).toHaveBeenCalledWith('zoomend', expect.any(Function))
})
it('FE-COMP-MAPVIEWGL-071: POI suggestions ride the map render clock instead of the pointer', async () => {
// Same drift the planned-place pins had: a library Marker repositions on every
// `move` event — one per pointer sample — while the canvas draws once a frame, so
// the suggestions swam over the map during a drag and snapped back on release.
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
render(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
pois={[{ osm_id: 'node:1', name: 'Aral', lat: 48.1, lng: 2.1, category: 'fuel', source: 'openstreetmap' } as never]}
/>,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
expect(layer.children.length).toBe(1)
const pin = layer.firstElementChild as HTMLElement
expect(pin.style.transform).toContain('translate(100px, 80px)')
glMap.project.mockReturnValue({ x: 140, y: 60 })
act(() => { mapHandler('render')() })
expect(pin.style.transform).toContain('translate(140px, 60px)')
})
// A via handle is a DOM element, and a pointerdown belongs to the element it happened
// on. Rebuilding the handles on an unrelated render pulled that element out from under
// the pointer, so a freshly placed via lost its first drag — the re-route it triggered
// landed about a second later, which is exactly when somebody reaches for it.
it('FE-COMP-MAPVIEWGL-VIA-001: a via handle survives a render it has nothing to do with', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
const { rerender } = render(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [stored] }}
onMoveVia={() => {}}
/>,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild
expect(handle).toBeTruthy()
// The same via in a fresh object, with a fresh callback: what every render of the
// planner hands down while the day re-routes.
rerender(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [{ ...stored }] }}
onMoveVia={() => {}}
/>,
)
await flushFrames()
expect(layer.firstElementChild).toBe(handle)
})
it('FE-COMP-MAPVIEWGL-VIA-002: a via that actually moved is drawn again', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
const { rerender } = render(
<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" roadtripVias={{ 1: [stored] }} onMoveVia={() => {}} />,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild
rerender(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [{ ...stored, lat: 48.9 }] }}
onMoveVia={() => {}}
/>,
)
await flushFrames()
// The other half of the contract: holding the element still must not mean holding it
// in the wrong place.
expect(layer.firstElementChild).not.toBe(handle)
})
})
+615 -74
View File
@@ -1,5 +1,10 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import type { RoadtripVia } from '@trek/shared'
import { useStableVias } from './viaMarkerState'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
@@ -81,6 +86,8 @@ interface RouteSegment {
// mouseleave never fires" case (#1404).
const NO_PLACES: Place[] = []
const NO_ROUTE_VIAS: RouteVia[] = []
/** Stable empty default: a fresh array each render would refire the spur effect. */
const NO_ACCESS_LINES: { line: [[number, number], [number, number]]; meters: number }[] = []
const NO_ROUTE_SEGMENTS: RouteSegment[] = []
const NO_DAY_ORDER: Record<number, number[] | null> = {}
const NO_RESERVATIONS: Reservation[] = []
@@ -97,6 +104,8 @@ interface Props {
tripId?: number | string
// Charging stops / rest areas a plugin route places on the drawn day route.
routeVias?: RouteVia[]
/** The dashed last bit to a place the road network does not reach. */
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
route?: [number, number][][] | null
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
@@ -121,6 +130,39 @@ interface Props {
onReservationClick?: (reservationId: number) => void
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
/**
* A corridor hit dropped somewhere on the map, with the coordinate it landed on.
* The caller decides whether that point is near enough to the drive to mean anything.
*/
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void
/** A click on the drawn route, for putting a via point there (#1797). */
onRouteClick?: (lat: number, lng: number) => void
/** The ways of driving one leg, drawn while the picker is open. */
alternativeRoutes?: AlternativeOverlay[]
/** Which option is being considered, so it can be lit up in its own colour. */
activeAlternative?: number | null
onChooseAlternative?: (index: number) => void
/** Reports which option the pointer is over, so the list and the map agree. */
onHighlightAlternative?: (index: number | null) => void
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
* for itself that it means "the selected day". Weighing the ways of driving one leg
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/**
* Let markers stay apart longer than usual.
*
* A road trip is read along a line: two stops fifty kilometres apart on the same
* motorway are the shape of the day, and merging them into one dot hides it.
*/
clusterLoosely?: boolean
/** Via points to draw as draggable handles, keyed by day (#1797). */
roadtripVias?: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
onRemoveVia?: (dayId: number, id: number) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
glProvider?: GlMapProvider
/**
@@ -134,7 +176,76 @@ interface Props {
onMapReady?: (map: any | null) => void
}
/** How eagerly place markers merge into a cluster, outside road trip mode. */
const CLUSTER_RADIUS = 30
const CLUSTER_MAX_ZOOM = 10
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addPlaceClusterLayers(map: any): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
// No number badge either, for the same reason the rail gives it none.
const service = serviceMarkerHtml(place.stop_type, selected)
if (service) {
const outer = serviceMarkerOuter(selected)
const wrap = document.createElement('div')
wrap.style.cssText = `width:${outer}px;height:${outer}px;display:flex;align-items:center;justify-content:center`
wrap.innerHTML = service
return wrap
}
const size = selected ? 44 : 36
// See MapView: allow-listed rather than escaped, because this is a CSS context.
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
@@ -356,6 +467,20 @@ function makePlacePin(map: any, layer: HTMLElement, el: HTMLElement, lng: number
}
}
/**
* Puts an element on the map as a pin.
*
* `layer` is only ever set under MapLibre (that is where it gets created), so passing it
* along is the whole provider decision: with a layer we position the element ourselves on
* the map's render clock, without one the library's own Marker does it.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function attachPin(map: any, gl: any, layer: HTMLElement | null, el: HTMLElement, lng: number, lat: number): PlacePin {
return layer
? makePlacePin(map, layer, el, lng, lat)
: libraryPin(new gl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map))
}
// Tone dot for a plugin marker — visual twin of MapPluginMarkers' divIcon.
function createPluginMarkerElement(tone: PluginMapMarker['tone']): HTMLDivElement {
const color = PLUGIN_TONE_COLORS[tone] ?? PLUGIN_TONE_COLORS.default
@@ -395,21 +520,37 @@ function buildPluginMarkerPopup(mk: PluginMapMarker): HTMLDivElement {
}
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
function createPoiMarkerElement(category: string): HTMLDivElement {
// A chain shows its logo instead of the category icon: on a corridor full of petrol
// stations the brand is what the eye is looking for, and the server proxies it so the
// browser never asks Wikimedia which ones are on screen.
function createPoiMarkerElement(category: string, brandWikidata?: string | null): HTMLDivElement {
const cat = POI_CATEGORY_BY_KEY[category]
const color = cat?.color || '#6b7280'
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
const el = document.createElement('div')
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
el.style.cssText = 'width:26px;height:26px;cursor:pointer;will-change:transform;'
el.innerHTML = `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;overflow:hidden;">${svg}${brandLogoMarkup(brandWikidata)}</div>`
return el
}
/**
* Kept as the one place that decides a POI pin carries no brand mark: nothing.
*
* The logos turned a corridor full of petrol stations into a row of advertisements, were
* unreadable at pin size, and a brand with no logo on file fell back to a different
* picture entirely — so no two pins looked alike. A flat disc in the category's colour
* with its icon is what the rail, the corridor list and the map all show now.
*/
export function brandLogoMarkup(_brandWikidata?: string | null): string {
return ''
}
export function MapViewGL({
places = NO_PLACES,
dayPlaces = NO_PLACES,
tripId,
routeVias = NO_ROUTE_VIAS,
accessLines = NO_ACCESS_LINES,
route = null,
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
@@ -420,6 +561,8 @@ export function MapViewGL({
center = DEFAULT_MAP_CENTER,
zoom = DEFAULT_MAP_ZOOM,
fitKey = 0,
focusPoints,
clusterLoosely = false,
dayOrderMap = NO_DAY_ORDER,
leftWidth = 0,
rightWidth = 0,
@@ -434,6 +577,15 @@ export function MapViewGL({
onReservationClick,
pois = NO_POIS,
onPoiClick,
onPoiDropOnRoute,
onRouteClick,
alternativeRoutes,
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
roadtripVias,
onMoveVia,
onRemoveVia,
onViewportChange,
glProvider = 'mapbox-gl',
gl,
@@ -480,42 +632,354 @@ export function MapViewGL({
const markersRef = useRef<Map<number, PlacePin>>(new Map())
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
const pinLayerRef = useRef<HTMLDivElement | null>(null)
const repositionPins = useCallback(() => {
markersRef.current.forEach(pin => pin.reposition())
}, [])
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poiMarkersRef = useRef<any[]>([])
const poiMarkersRef = useRef<PlacePin[]>([])
/** Undoes the drag wiring on each POI pin; the pins themselves are rebuilt wholesale. */
const poiCleanupRef = useRef<(() => void)[]>([])
const onPoiDropRef = useRef(onPoiDropOnRoute)
onPoiDropRef.current = onPoiDropOnRoute
// Plugin map contributions — data fetched per trip, elements owned imperatively
// like the POI markers so they survive the React render cycle.
const [pluginMarkers, setPluginMarkers] = useState<PluginMapMarker[]>([])
const [pluginLayers, setPluginLayers] = useState<PluginMapLayer[]>([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginMarkersRef = useRef<any[]>([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const routeViaMarkersRef = useRef<any[]>([])
const pluginMarkersRef = useRef<PlacePin[]>([])
const routeViaMarkersRef = useRef<PlacePin[]>([])
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
const viaPinsRef = useRef<PlacePin[]>([])
/** The drive-time pills on the offered routes; same treatment. */
const altLabelsRef = useRef<PlacePin[]>([])
// Every hand-positioned pin, whichever set it belongs to. They all have to be written
// in the same frame the canvas draws, or the ones left out swim against the ones in.
const repositionPins = useCallback(() => {
markersRef.current.forEach(pin => pin.reposition())
poiMarkersRef.current.forEach(pin => pin.reposition())
pluginMarkersRef.current.forEach(pin => pin.reposition())
routeViaMarkersRef.current.forEach(pin => pin.reposition())
viaPinsRef.current.forEach(pin => pin.reposition())
altLabelsRef.current.forEach(pin => pin.reposition())
}, [])
// Single reusable hover popup for POI markers. Planned places use the
// cursor-following React tooltip below so they match the Leaflet map.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const popupRef = useRef<any | null>(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
/**
* The via handles.
*
* Built as plain elements and attached with the same `attachPin` the place pins use,
* rather than the library's own marker: under MapLibre those float above the map during
* a drag instead of sticking to the ground (the reason `attachPin` exists at all). The
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
*/
const viaCleanupRef = useRef<(() => void)[]>([])
/**
* The list only changes when a via does, and the callbacks are read through a ref.
*
* Both matter for the same reason: this effect destroys every handle and builds new DOM
* elements. It used to re-run on every render of the planner — the vias arrived as a
* fresh object each time and `onMoveVia` as a fresh closure — so the render that landed
* when a freshly placed via finished re-routing pulled the element out from under the
* pointer, and the first drag went nowhere.
*/
const vias = useStableVias(roadtripVias)
const viaHandlersRef = useRef({ onMoveVia, onRemoveVia })
viaHandlersRef.current = { onMoveVia, onRemoveVia }
const viasDraggable = !!onMoveVia
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
viaCleanupRef.current.forEach(off => off())
viaCleanupRef.current = []
viaPinsRef.current.forEach(p => p.remove())
viaPinsRef.current = []
for (const via of vias) {
const el = document.createElement('span')
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat)
viaPinsRef.current.push(pin)
if (!viasDraggable) continue
let dragging = false
// The map starts panning on mousedown/touchstart anywhere on its surface, and a pin
// sits on that surface. Stopping only pointerdown left both running at once: the
// handle moved AND the map slid out from under it.
const swallow = (e: Event) => e.stopPropagation()
// Where the gesture began, so a click can be told from a drag. Without it
// every pointerup committed a move to wherever inside the 17px handle the
// pointer happened to be: at a low zoom that is kilometres, and the write
// re-routes the whole trip. Leaflet gets this for free from `dragend`,
// which is why the two renderers behaved differently for one gesture.
let startedAt: { x: number; y: number } | null = null
const MOVED_ENOUGH = 4
const onDown = (e: PointerEvent) => {
// Primary button only. Right-clicking ran onDown, then contextmenu ->
// remove AND pointerup -> move, so one gesture sent two writes; on the
// platforms where contextmenu comes first, the move landed on an id that
// no longer existed and reported a failure for a deletion that worked.
if (e.button !== 0) return
e.stopPropagation()
e.preventDefault()
dragging = true
startedAt = { x: e.clientX, y: e.clientY }
el.setPointerCapture(e.pointerId)
el.style.cursor = 'grabbing'
}
const onMove = (e: PointerEvent) => {
if (!dragging) return
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
pin.setLngLat([at.lng, at.lat])
}
const onUp = (e: PointerEvent) => {
if (!dragging) return
dragging = false
el.style.cursor = 'grab'
const from = startedAt
startedAt = null
const travelled = from ? Math.hypot(e.clientX - from.x, e.clientY - from.y) : 0
if (travelled < MOVED_ENOUGH) {
// A click, not a drag. Put the pin back where the via actually is: the
// move handler has been following the pointer across the handle.
pin.setLngLat([via.lng, via.lat])
return
}
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
}
// The browser fires this when the captured element is torn out of the DOM,
// which is exactly what a delete does. Committing a position there wrote a
// move for a via that was being removed.
const onCancel = () => {
if (!dragging) return
dragging = false
startedAt = null
el.style.cursor = 'grab'
pin.setLngLat([via.lng, via.lat])
}
const onContext = (e: MouseEvent) => {
e.preventDefault()
viaHandlersRef.current.onRemoveVia?.(via.day_id, via.id)
}
el.addEventListener('pointerdown', onDown)
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
el.addEventListener('pointercancel', onCancel)
el.addEventListener('contextmenu', onContext)
el.addEventListener('mousedown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('dblclick', swallow)
viaCleanupRef.current.push(() => {
el.removeEventListener('pointerdown', onDown)
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
el.removeEventListener('pointercancel', onCancel)
el.removeEventListener('contextmenu', onContext)
el.removeEventListener('mousedown', swallow)
el.removeEventListener('touchstart', swallow)
el.removeEventListener('dblclick', swallow)
})
}
}, [vias, viasDraggable, mapReady, glProvider])
/**
* The offered routes, drawn under the current one.
*
* Grey and dashed rather than a set of bright lines: at any moment one of them IS the
* route, drawn in blue on top, and three more solid colours next to it would read as
* four equal roads. Hovering an option in the bar lights that one up in its own colour,
* which is the only moment a second colour helps.
*/
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const source = map.getSource?.('route-alternatives')
if (!source?.setData) return
source.setData({
type: 'FeatureCollection',
features: (alternativeRoutes ?? []).map((alt, i) => ({
type: 'Feature',
properties: { index: i, active: activeAlternative === i, color: alt.color },
geometry: { type: 'LineString', coordinates: alt.coordinates.map(([lat, lng]) => [lng, lat]) },
})),
})
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
/**
* The drive time on each offered route, the way Apple Maps labels them.
*
* Hand-positioned pins rather than a symbol layer: a symbol would need the text in the
* style's own font stack, which differs between the bundled MapLibre styles and Mapbox,
* and the pill has a shape a text layer cannot draw. They join the render clock like
* every other pin here, so they stay on their road while the map moves.
*/
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
altLabelsRef.current.forEach(p => p.remove())
altLabelsRef.current = []
for (const alt of alternativeRoutes ?? []) {
const el = document.createElement('button')
el.type = 'button'
const active = activeAlternative === alt.index
el.style.cssText = [
// A rounded rectangle rather than a pill, and roomy enough for a second line —
// the shape Apple gives these, and the reason "1 h 55 min / Fastest" reads as one
// label instead of two stacked chips.
'display:flex;flex-direction:column;align-items:flex-start;white-space:nowrap',
`background:${alt.labelBg}`,
`color:${ALT_LABEL_TEXT}`,
'border:none;border-radius:11px',
// A <button> does not inherit the page's font, so without this the label reads in
// the browser's UI face while every other piece of TREK chrome reads in Poppins.
'font-family:var(--font-system)',
'padding:6px 11px;font-size:13px;font-weight:600;line-height:1.25',
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
'cursor:pointer;pointer-events:auto',
// The pin is positioned by writing `transform` every frame. Any inherited
// transition on it makes the label chase the map instead of sticking to the road,
// and `transform` is exactly what a button picks up from the app's own styles.
'transition:none !important;animation:none !important',
'will-change:transform;backface-visibility:hidden',
active ? 'outline:2px solid #fff;outline-offset:1px' : '',
].filter(Boolean).join(';')
const time = document.createElement('span')
time.textContent = alt.label
el.appendChild(time)
if (alt.note) {
const note = document.createElement('span')
note.textContent = alt.note
note.style.cssText = 'font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2'
el.appendChild(note)
}
// Same reason as the via handles: without this the map pans as the label is pressed.
el.addEventListener('mousedown', e => e.stopPropagation())
el.addEventListener('touchstart', e => e.stopPropagation(), { passive: true })
el.addEventListener('click', e => { e.stopPropagation(); onChooseAlternativeRef.current?.(alt.index) })
el.addEventListener('mouseenter', () => onHighlightAlternativeRef.current?.(alt.index))
el.addEventListener('mouseleave', () => onHighlightAlternativeRef.current?.(null))
altLabelsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, alt.at.lng, alt.at.lat))
}
return () => {
altLabelsRef.current.forEach(p => p.remove())
altLabelsRef.current = []
}
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
/** Clicking one of the offered routes takes it, the same as clicking its chip. */
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || !onChooseAlternative) return
const onClick = (e: { features?: { properties?: { index?: number } }[] }) => {
const index = e.features?.[0]?.properties?.index
if (typeof index === 'number') onChooseAlternative(index)
}
const enter = () => { map.getCanvas().style.cursor = 'pointer' }
const leave = () => { map.getCanvas().style.cursor = '' }
if (!map.getLayer?.('route-alt-hit')) return
map.on('click', 'route-alt-hit', onClick)
map.on('mouseenter', 'route-alt-hit', enter)
map.on('mouseleave', 'route-alt-hit', leave)
return () => {
map.off('click', 'route-alt-hit', onClick)
map.off('mouseenter', 'route-alt-hit', enter)
map.off('mouseleave', 'route-alt-hit', leave)
}
}, [mapReady, onChooseAlternative])
/**
* A click on the route line, which in a GL renderer is a layer rather than an element.
* `map.on(type, layerId, …)` is the one way to hit it, and it fires before the map's own
* click, so the handler can keep the add-place menu from opening underneath the via.
*/
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || !onRouteClick) return
const onClick = (e: { lngLat: { lat: number; lng: number }; point?: { x: number; y: number }; preventDefault?: () => void }) => {
e.preventDefault?.()
// Layer handlers are independent: MapLibre evaluates each registration
// against the same click, so a point that hits both the offered-route band
// and the current-route band fires both. Every alternative starts and ends
// at the same two stops, and when the leg already carries vias the current
// road is itself one of the offers, so the overlap is the whole leg. One
// click then dropped a via AND chose a different road, two writes and two
// trip reloads for a gesture the user made once. The picker is a modal
// choice about that leg; dropping a via mid-choice is not offered anywhere.
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
onRouteClick(e.lngLat.lat, e.lngLat.lng)
}
const enter = () => { map.getCanvas().style.cursor = 'copy' }
const leave = () => { map.getCanvas().style.cursor = '' }
for (const layer of ['trip-route-hit']) {
if (!map.getLayer?.(layer)) continue
map.on('click', layer, onClick)
map.on('mouseenter', layer, enter)
map.on('mouseleave', layer, leave)
}
return () => {
for (const layer of ['trip-route-hit']) {
map.off('click', layer, onClick)
map.off('mouseenter', layer, enter)
map.off('mouseleave', layer, leave)
}
}
}, [mapReady, onRouteClick])
/**
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
* canvas and has no element to aim at. The coordinate under the pointer says just as
* much once it is projected onto the routed geometry, and it works the same in all
* three renderers.
*/
useEffect(() => {
const map = mapRef.current
const container = map?.getContainer?.()
if (!container || !mapReady || !onPoiDropOnRoute) return
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [mapReady, onPoiDropOnRoute])
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
onClickRefs.current.map = onMapClick
onClickRefs.current.context = onMapContextMenu
const hoverDisabledRef = useRef(hoverDisabled)
hoverDisabledRef.current = hoverDisabled
// Read inside the map's own click handler, which is registered once at setup.
const onRouteClickRef = useRef(onRouteClick)
onRouteClickRef.current = onRouteClick
const onChooseAlternativeRef = useRef(onChooseAlternative)
onChooseAlternativeRef.current = onChooseAlternative
const onHighlightAlternativeRef = useRef(onHighlightAlternative)
onHighlightAlternativeRef.current = onHighlightAlternative
// Same gate as the Leaflet renderer: HTML5 drag is a pointer feature, and the
// day plan the marker would be dropped on is not on screen on a phone anyway.
const markersDraggableRef = useRef(typeof window !== 'undefined' && navigator.maxTouchPoints === 0)
@@ -599,6 +1063,49 @@ export function MapViewGL({
if (glStyle === MAPBOX_DEFAULT_STYLE) {
try { map.setTerrain(null) } catch { /* noop */ }
}
// The ways of driving one leg, offered while the picker is open (#1797). Added
// BEFORE the route source so its layers sit underneath: the current route stays the
// brightest thing on the map, and the options read as what they are — suggestions.
if (!map.getSource('route-alternatives')) {
map.addSource('route-alternatives', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
// A wide invisible band per option, so a pointer can pick one without hitting
// the 3px line exactly.
map.addLayer({
id: 'route-alt-hit',
type: 'line',
source: 'route-alternatives',
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 22 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// A white casing under the option lines, the same trick the main route uses.
// Plain grey disappeared on a grey basemap — Positron is mostly greys, and a
// grey dashed line on it is invisible. The casing gives every option an edge, so
// it reads on a pale map, a dark one and a satellite tile alike.
map.addLayer({
id: 'route-alt-casing',
type: 'line',
source: 'route-alternatives',
paint: { 'line-color': ALT_CASING, 'line-width': 8, 'line-opacity': 0.9 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'route-alt-line',
type: 'line',
source: 'route-alternatives',
// Dark and dashed by default so it stays subordinate to the blue route it is an
// alternative to; the one being considered takes its own colour and thickens.
// Every option in blue, after Apple Maps: they are all real roads, so the
// difference between them is emphasis rather than category. The one being
// considered thickens; nothing turns grey, because grey vanishes on Positron.
paint: {
'line-color': ['coalesce', ['get', 'color'], '#7eb8f0'],
'line-width': ['case', ['boolean', ['get', 'active'], false], 6, 4],
'line-opacity': ['case', ['boolean', ['get', 'active'], false], 1, 0.9],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
// initial route source — kept around so updates can setData() cheaply
if (!map.getSource('trip-route')) {
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
@@ -611,6 +1118,16 @@ export function MapViewGL({
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// An invisible band over the route, purely to be clicked. The drawn line is 8px
// and a pointer is not that accurate, so aiming at the road was most of why
// putting a via there felt like it did not work.
map.addLayer({
id: 'trip-route-hit',
type: 'line',
source: 'trip-route',
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 26 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'trip-route-line',
type: 'line',
@@ -619,6 +1136,21 @@ export function MapViewGL({
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
// The last bit to a place the road does not reach (#1797). Added after the route
// so it draws over it: it is the one piece of the line that is not driving, and it
// has to be readable exactly where it meets the road it leaves. Same blue, because
// it is the end of that route rather than a second one; dashed short and thin,
// which is how a map says "on foot from here" without a legend.
if (!map.getSource('trip-access')) {
map.addSource('trip-access', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({
id: 'trip-access-line',
type: 'line',
source: 'trip-access',
paint: { 'line-color': '#0a84ff', 'line-width': 3, 'line-opacity': 0.85, 'line-dasharray': [1, 2.5] },
layout: { 'line-cap': 'round' },
})
}
// gpx geometries source (place.route_geometry)
if (!map.getSource('trip-gpx')) {
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
@@ -681,53 +1213,7 @@ export function MapViewGL({
map.on('mouseleave', GPX_HIT_LAYER_ID, clearTrackCursor)
}
if (!map.getSource(PLACE_CLUSTER_SOURCE_ID)) {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: 30,
clusterMaxZoom: 10,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
addPlaceClusterLayers(map)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const zoomToCluster = (e: any) => {
const features = typeof map.queryRenderedFeatures === 'function'
@@ -845,6 +1331,15 @@ export function MapViewGL({
&& typeof map.queryRenderedFeatures === 'function'
&& map.queryRenderedFeatures(e.point, { layers: [GPX_HIT_LAYER_ID] }).length > 0
) return
// And for the drive itself while it can be reshaped: that click just put a via
// there, and it must not also drop a place on top of it (#1797).
if (
onRouteClickRef.current
&& typeof map.getLayer === 'function'
&& map.getLayer('trip-route-hit')
&& typeof map.queryRenderedFeatures === 'function'
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
})
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
@@ -1168,12 +1663,7 @@ export function MapViewGL({
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
// but it rotates the element by the pitch angle and visually offsets
// the anchor by ~100px at 45° tilt, which caused the observed drift.
const pin = isMapLibre && pinLayerRef.current
? makePlacePin(map, pinLayerRef.current, el, place.lng, place.lat)
: libraryPin(new gl.Marker({ element: el, anchor: 'center' })
.setLngLat([place.lng, place.lat])
.addTo(map))
markersRef.current.set(place.id, pin)
markersRef.current.set(place.id, attachPin(map, gl, pinLayerRef.current, el, place.lng, place.lat))
})
}
@@ -1185,6 +1675,18 @@ export function MapViewGL({
return
}
// Road trip mode does not cluster at all: the whole drive has to stay readable
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
// is being looked at for. The source is emptied rather than reconfigured — the
// cluster options can only be set when a GeoJSON source is created, and dropping and
// re-adding a source under three layers to change a radius is not worth it when the
// markers are drawn from `places` anyway.
if (clusterLoosely) {
source.setData({ type: 'FeatureCollection', features: [] })
reconcileMarkers(validPlaces)
return
}
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
@@ -1223,7 +1725,7 @@ export function MapViewGL({
map.off('zoomend', scheduleReconcile)
map.off('idle', scheduleReconcile)
}
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them).
@@ -1233,15 +1735,19 @@ export function MapViewGL({
popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category)
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m)
// Dragging a hit onto the drive puts it where it is passed, rather than where the
// corridor happened to project it. Only wired when someone is listening for it.
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
}
}, [pois, mapReady, glProvider])
@@ -1287,8 +1793,7 @@ export function MapViewGL({
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
})
}
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([v.lng, v.lat]).addTo(map)
routeViaMarkersRef.current.push(m)
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
}
}, [routeVias, mapReady, glProvider])
@@ -1306,7 +1811,7 @@ export function MapViewGL({
popupRef.current?.setLngLat([mk.lng, mk.lat]).setDOMContent(buildPluginMarkerPopup(mk)).addTo(map)
})
}
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([mk.lng, mk.lat]).addTo(map)
const m = attachPin(map, gl, pinLayerRef.current, el, mk.lng, mk.lat)
pluginMarkersRef.current.push(m)
}
}, [pluginMarkers, mapReady, glProvider])
@@ -1325,6 +1830,22 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features })
}, [route, mapReady])
// Update access-spur geojson
useEffect(() => {
const map = mapRef.current
if (!map) return
const src = map.getSource('trip-access') as mapboxgl.GeoJSONSource | undefined
if (!src) return
src.setData({
type: 'FeatureCollection',
features: (accessLines || []).map(spur => ({
type: 'Feature' as const,
properties: { meters: Math.round(spur.meters) },
geometry: { type: 'LineString' as const, coordinates: spur.line.map(([lat, lng]) => [lng, lat]) },
})),
})
}, [accessLines, mapReady])
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
// Update GPX geometries
@@ -1452,6 +1973,25 @@ export function MapViewGL({
if (routeArrivedForPendingFit) pendingRouteFitRef.current = null
}, [fitKey, routeFitKey]) // eslint-disable-line react-hooks/exhaustive-deps
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
const map = mapRef.current
if (!map || !focusPoints?.length) return
const bounds = new gl.LngLatBounds()
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
// A day fit still waiting on its route must not overwrite this a moment later.
pendingRouteFitRef.current = null
try {
map.fitBounds(bounds, {
padding: paddingOpts,
maxZoom: 15,
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
})
} catch { /* noop */ }
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place
useEffect(() => {
const map = mapRef.current
@@ -1548,6 +2088,7 @@ export function MapViewGL({
<LocationButton
mode={trackingMode}
error={trackingError}
errorCode={trackingErrorCode}
onClick={cycleTrackingMode}
bottomOffset={buttonBottom as unknown as number}
/>
@@ -0,0 +1,85 @@
import React, { useMemo, useRef } from 'react'
import { Marker, Tooltip } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { useStableVias } from './viaMarkerState'
import type { RoadtripVia } from '@trek/shared'
/**
* The handles that shape a drive.
*
* A via is deliberately small and unnumbered: it is not a stop, and anything that looks
* like one would read as a place on the itinerary. Dragging it redraws the route through
* the new spot, which is the whole interaction — the same one a paper map gets when you
* put a finger on the road you actually want to take.
*/
const VIA_ICON = L.divIcon({
className: '',
html: `<span style="
display:block;width:12px;height:12px;border-radius:9999px;
background:#0a84ff;border:2.5px solid #ffffff;
box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;
"></span>`,
iconSize: [12, 12],
iconAnchor: [6, 6],
})
interface RoadtripViaMarkersProps {
viasByDay: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
onRemoveVia?: (dayId: number, id: number) => void
}
/** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */
export default function RoadtripViaMarkers({
viasByDay, onMoveVia, onRemoveVia,
}: RoadtripViaMarkersProps): React.ReactElement | null {
const { t } = useTranslation()
const vias = useStableVias(viasByDay)
// Read through a ref so the handlers below can stay the same objects across renders.
// Both callbacks are rebuilt on every render of the planner, and react-leaflet compares
// its props by identity: a fresh `eventHandlers` unbinds and rebinds every listener,
// and a fresh `position` array calls `setLatLng` on every marker. Doing either while a
// drag is in flight is how a freshly placed via lost its first drag.
const handlersRef = useRef({ onMoveVia, onRemoveVia })
handlersRef.current = { onMoveVia, onRemoveVia }
const draggable = !!onMoveVia
const markers = useMemo(() => vias.map(via => ({
via,
position: [via.lat, via.lng] as [number, number],
handlers: {
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
const at = e.target.getLatLng()
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
},
// Right-click rather than a delete handle: a 12px dot has no room for one,
// and the same gesture removes things elsewhere on the map.
contextmenu: (e: { originalEvent: MouseEvent }) => {
e.originalEvent.preventDefault()
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
},
},
})), [vias])
if (!markers.length) return null
return (
<>
{markers.map(({ via, position, handlers }) => (
<Marker
key={`via-${via.id}`}
position={position}
icon={VIA_ICON}
draggable={draggable}
zIndexOffset={400}
eventHandlers={handlers}
>
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
{t('roadtrip.via.hint')}
</Tooltip>
</Marker>
))}
</>
)
}
+262 -13
View File
@@ -12,10 +12,11 @@ import {
generateCoMapsUrl,
parsePluginProfile,
withHotelBookends,
calculateAlternatives,
} from './RouteCalculator'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
// calculateRouteWithLegs talks to the FOSSGIS per-profile hosts, not the car-only demo.
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
// demo ignored the profile in the URL, so walking routes followed the road network.
const FOSSGIS = {
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
@@ -46,7 +47,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse())
)
)
@@ -56,7 +57,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(1500, 360))
)
)
@@ -66,7 +67,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(800, 360))
)
)
@@ -78,7 +79,7 @@ describe('calculateRoute', () => {
const distance = 5000
const osrmDuration = 999
server.use(
http.get(`${OSRM_BASE}/walking/:coords`, () =>
http.get(`${FOSSGIS.walking}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration))
)
)
@@ -90,7 +91,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json({}, { status: 500 })
)
)
@@ -99,7 +100,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json({ code: 'NoRoute', routes: [] })
)
)
@@ -108,7 +109,7 @@ describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse())
)
)
@@ -128,7 +129,7 @@ describe('calculateSegments', () => {
it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json({
code: 'Ok',
routes: [
@@ -325,7 +326,7 @@ describe('parsePluginProfile', () => {
describe('calculateRoute profiles', () => {
it('FE-COMP-ROUTECALCULATOR-030: cycling overrides the OSRM duration with a 15 km/h estimate', async () => {
server.use(
http.get(`${OSRM_BASE}/cycling/:coords`, () =>
http.get(`${FOSSGIS.cycling}/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(9000, 4242))
)
)
@@ -340,12 +341,12 @@ describe('calculateRoute profiles', () => {
describe('calculateSegments failures', () => {
it('FE-COMP-ROUTECALCULATOR-031: throws when OSRM answers with an HTTP error', async () => {
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({}, { status: 502 })))
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 502 })))
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('Route could not be calculated')
})
it('FE-COMP-ROUTECALCULATOR-032: throws when OSRM reports no usable route', async () => {
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('No route found')
})
})
@@ -613,3 +614,251 @@ describe('generateCoMapsUrl', () => {
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
})
})
describe('a self-hosted routing engine', () => {
afterEach(() => {
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
})
const useOwnRouter = (base: string) =>
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: base } }))
it('FE-COMP-ROUTECALCULATOR-021: a configured instance is asked instead of the public hosts', async () => {
useOwnRouter('https://osrm.example.org')
let asked = ''
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
expect(asked).toContain('osrm.example.org')
})
it('FE-COMP-ROUTECALCULATOR-022: OSRM’s own profile names are used, not TREK’s', async () => {
useOwnRouter('https://osrm.example.org')
let asked = ''
// TREK says "walking" and "cycling"; osrm-routed serves "foot" and "bike".
server.use(http.get('https://osrm.example.org/route/v1/foot/:coords', ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'walking')
expect(asked).toContain('/route/v1/foot/')
})
it('FE-COMP-ROUTECALCULATOR-023: a trailing slash in the setting does not double up', async () => {
useOwnRouter('https://osrm.example.org///')
let asked = ''
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
expect(asked).not.toContain('//route')
})
it('FE-COMP-ROUTECALCULATOR-024: blank or whitespace falls back to the public hosts', async () => {
useOwnRouter(' ')
let asked = ''
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
asked = request.url
return HttpResponse.json(buildOsrmRouteResponse())
}))
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
expect(asked).toContain('routing.openstreetmap.de')
})
})
describe('calculateAlternatives', () => {
const twoRoutes = {
code: 'Ok',
routes: [
// The direct one, straight down the middle.
{ geometry: { coordinates: [[10, 53], [10.5, 52.5], [11, 52]] }, distance: 100000, duration: 3600 },
// A detour that swings well to the east before rejoining.
{ geometry: { coordinates: [[10, 53], [12.5, 52.5], [11, 52]] }, distance: 130000, duration: 4500 },
],
}
it('FE-COMP-ROUTECALCULATOR-025: asks for alternatives between exactly two points', async () => {
let asked = ''
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
asked = request.url
return HttpResponse.json(twoRoutes)
}))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(asked).toContain('alternatives=3')
expect(routes).toHaveLength(2)
expect(routes[0].distance).toBe(100000)
})
it('FE-COMP-ROUTECALCULATOR-026: each alternative carries the point that makes it different', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(twoRoutes)))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
// The first is what the router gives anyway, so it needs no pinning point.
expect(routes[0].divergence).toBeNull()
// The second diverges at its eastern swing — saving that as a via forces this road.
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
})
/** The router's own answer, and whatever it says when a road class is left out. */
const withExclusions = (byExclude: Record<string, unknown>) =>
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
const answer = byExclude[exclude]
return answer ? HttpResponse.json(answer) : HttpResponse.json({ code: 'NoRoute', routes: [] })
})
it('FE-COMP-ROUTECALCULATOR-027: a road that is the same road either way is not offered twice', async () => {
// Leaving the motorway out changed nothing — the leg does not touch one. Offering
// the identical line a second time would be a choice that is not a choice.
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[0]] }, toll: { code: 'Ok', routes: [twoRoutes.routes[0]] } }))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(routes).toHaveLength(1)
expect(routes[0].divergence).toBeNull()
})
it('FE-COMP-ROUTECALCULATOR-029: one answer from the router still offers the motorway-free way', async () => {
// OSRM answers most long legs with exactly one route, which used to mean the picker
// said there was no other way — while a perfectly good slower road existed.
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[1]] } }))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(routes).toHaveLength(2)
expect(routes[1].avoids).toBe('motorway')
// Pinnable like any other offer: the point where it leaves the direct line.
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
})
it('FE-COMP-ROUTECALCULATOR-030: tolls are only asked about when the motorway gave nothing', async () => {
const asked: string[] = []
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (exclude) asked.push(exclude)
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
if (exclude === 'motorway') return HttpResponse.json({ code: 'NoRoute', routes: [] })
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
}))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(asked).toEqual(['motorway', 'toll'])
expect(routes[1].avoids).toBe('toll')
})
it('FE-COMP-ROUTECALCULATOR-031: walking is never asked to leave out a road class', async () => {
// The foot profile has no excludable classes; asking earns an InvalidOptions for
// nothing, and a walk has no motorway to avoid in the first place.
const asked: string[] = []
server.use(http.get(`${FOSSGIS.walking}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (exclude) asked.push(exclude)
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
}))
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }, 'walking')
expect(asked).toEqual([])
expect(routes).toHaveLength(1)
})
it('FE-COMP-ROUTECALCULATOR-032: a host that rejects exclude is only asked once', async () => {
// Both public hosts TREK ships with answer 400 here, so without this every long leg
// pays two extra requests against a one-per-second limit to be refused twice.
const asked: string[] = []
server.use(http.get('*/route/v1/driving/*', ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
asked.push(exclude)
return new HttpResponse(null, { status: 400 })
}))
await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 13 }, 'driving')
const afterFirst = asked.length
await calculateAlternatives({ lat: 51, lng: 9 }, { lat: 50, lng: 12 }, 'driving')
expect(afterFirst).toBe(1)
expect(asked).toHaveLength(1)
})
it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => {
// OSRM reports these in every answer and TREK threw them away since the first route
// was drawn. Without them a place set back from the road looks like it is on the
// route, while the drive really starts somewhere else.
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
code: 'Ok',
waypoints: [
{ location: [10.02, 53.51], distance: 812.5 },
{ location: [13.0, 52.0], distance: 4 },
],
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
})))
const r = await calculateRouteWithLegs([{ lat: 53.5, lng: 10.0 }, { lat: 52, lng: 13 }])
expect(r.snapped).toEqual([
{ asked: [53.5, 10.0], at: [53.51, 10.02], meters: 812.5 },
{ asked: [52, 13], at: [52.0, 13.0], meters: 4 },
])
})
it('FE-COMP-ROUTECALCULATOR-034: a distance the router left out is worked out from the two points', async () => {
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
code: 'Ok',
waypoints: [{ location: [10.0, 53.0] }, { location: [13.0, 52.0] }],
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
})))
const r = await calculateRouteWithLegs([{ lat: 53.01, lng: 10.0 }, { lat: 52, lng: 13 }])
// Roughly 1.1 km for a hundredth of a degree of latitude; the point is that it is a
// real number rather than the whole answer being discarded.
expect(r.snapped?.[0].meters).toBeGreaterThan(1000)
expect(r.snapped?.[0].meters).toBeLessThan(1200)
})
it('FE-COMP-ROUTECALCULATOR-035: an answer that does not describe its waypoints changes nothing', async () => {
// All-or-nothing: a partial list would have to be indexed by waypoint anyway, and one
// bad entry would hang the spur off the wrong stop.
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
code: 'Ok',
waypoints: [{ location: [10.0, 53.0] }],
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
})))
const r = await calculateRouteWithLegs([{ lat: 53, lng: 10 }, { lat: 52, lng: 13 }])
expect(r.snapped).toBeUndefined()
expect(r.coordinates).toHaveLength(2)
})
it('FE-COMP-ROUTECALCULATOR-028: no route at all is an empty list, not a throw', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json({ code: 'NoRoute', routes: [] })))
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })).resolves.toEqual([])
})
it('FE-COMP-ROUTECALCULATOR-029: a refused request is a RoutingRefusedError, not a generic one', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 429 })))
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }))
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
})
})
+286 -11
View File
@@ -1,10 +1,9 @@
import { useSettingsStore } from '../../store/settingsStore'
import { pluginsApi } from '../../api/client'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
// the matching profile so walking routes follow footpaths, not the road network.
@@ -14,6 +13,74 @@ const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
}
/** OSRM's own profile names, which a self-hosted instance serves under its own paths. */
const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = {
driving: 'driving',
walking: 'foot',
cycling: 'bike',
}
/**
* Where to ask for a route.
*
* The public FOSSGIS hosts allow roughly one request a second, which a road trip — every
* leg of every day — runs into immediately. An instance that runs its own OSRM sets
* `routing_base_url` and everything routes against that instead; the server has to name
* the same origin in its connect-src, or the browser blocks the requests silently.
*
* A configured base is expected to serve the standard OSRM layout,
* `<base>/route/v1/<profile>/…`, which is what `osrm-routed` does out of the box.
*/
/**
* A configured base URL without its trailing slashes.
*
* Walked rather than matched with `/\/+$/`: that pattern backtracks over a run of
* slashes, and the value comes from an instance setting somebody types. Linear either
* way in practice, but the regex is the shape a scanner is right to flag.
*/
function withoutTrailingSlashes(value: string): string {
let end = value.length
while (end > 0 && value[end - 1] === '/') end -= 1
return value.slice(0, end)
}
function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
const configured = useSettingsStore.getState().settings.routing_base_url?.trim()
if (!configured) return OSRM_PROFILE_BASE[profile]
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
}
/**
* A routing request the host refused, as opposed to one it answered with "no route".
*
* The public OSRM instances allow roughly one request a second and answer 429 above that.
* Both used to arrive as the same generic Error, so a caller could not tell "back off and
* try again" from "these two points are not connected by road" — and a road trip, which
* asks for every leg of every day, hits the first case constantly while the second is
* rare. `retryAfterMs` carries the host's own `Retry-After` when it sends one.
*/
export class RoutingRefusedError extends Error {
constructor(readonly status: number, readonly retryAfterMs: number | null) {
super(status === 429 ? 'Routing rate limit reached' : 'Route could not be calculated')
this.name = 'RoutingRefusedError'
}
/** True when waiting and asking again is the right response. */
get isRateLimit(): boolean {
return this.status === 429 || this.status === 503
}
}
/** `Retry-After` is either seconds or an HTTP date; anything else is no answer at all. */
function retryAfterMs(response: Response): number | null {
const raw = response.headers.get('Retry-After')
if (!raw) return null
const seconds = Number(raw)
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)
const at = Date.parse(raw)
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
}
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
const routeCache = new Map<string, RouteWithLegs>()
@@ -45,11 +112,11 @@ export async function calculateRoute(
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error('Route could not be calculated')
throw new RoutingRefusedError(response.status, retryAfterMs(response))
}
const data = await response.json()
@@ -252,10 +319,10 @@ export async function calculateSegments(
if (!waypoints || waypoints.length < 2) return []
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Route could not be calculated')
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
@@ -344,10 +411,13 @@ export async function calculateRouteWithLegs(
return result
}
const osrmProfile = (profile === 'walking' || profile === 'cycling') ? profile : 'driving'
const url = `${OSRM_PROFILE_BASE[osrmProfile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
// Written as literals rather than narrowing `profile`: its type is an open string union
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
const osrmProfile: 'driving' | 'walking' | 'cycling' =
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Route could not be calculated')
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
@@ -374,7 +444,8 @@ export async function calculateRouteWithLegs(
}
)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
const snapped = readSnapped(data, waypoints)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
routeCache.set(cacheKey, result)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
@@ -383,6 +454,37 @@ export async function calculateRouteWithLegs(
return result
}
/**
* The snap positions out of an OSRM answer, or undefined if it did not describe them.
*
* OSRM reports this in every response and TREK has thrown it away since the first route
* was drawn. `distance` is the straight line from the coordinate we sent to the road it
* used; it is recomputed from the two points when the field is missing or nonsense, so a
* mirror that trims its answers still produces a usable gap rather than a wrong one.
*
* All-or-nothing on purpose: a partial list would have to be indexed by waypoint anyway,
* and one bad entry would put a spur on the wrong stop.
*/
function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] | undefined {
const raw = (data as { waypoints?: unknown })?.waypoints
if (!Array.isArray(raw) || raw.length !== waypoints.length) return undefined
const out: SnappedWaypoint[] = []
for (let i = 0; i < raw.length; i++) {
const loc = (raw[i] as { location?: unknown })?.location
if (!Array.isArray(loc) || loc.length < 2) return undefined
const [lng, lat] = loc as [number, number]
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
const asked: [number, number] = [waypoints[i].lat, waypoints[i].lng]
const at: [number, number] = [lat, lng]
const reported = (raw[i] as { distance?: unknown })?.distance
const meters = typeof reported === 'number' && Number.isFinite(reported) && reported >= 0
? reported
: haversineKm({ lat: asked[0], lng: asked[1] }, { lat, lng }) * 1000
out.push({ asked, at, meters })
}
return out
}
function getDistanceUnit(): DistanceUnit {
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
}
@@ -403,3 +505,176 @@ function formatDuration(seconds: number): string {
}
return `${m} min`
}
/** One way of driving a leg, as OSRM offers it. */
export interface RouteAlternative {
coordinates: [number, number][]
distance: number
duration: number
/** Where this route differs most from the first one — the point that would pin it. */
divergence: { lat: number; lng: number } | null
/** Set when this way exists because a road class was left out of it. */
avoids?: 'motorway' | 'toll'
}
/**
* Road classes worth asking the router to leave out, in the order they are tried.
*
* OSRM's own alternative search is conservative: it only offers a second road when the
* detour is short enough and shares little enough with the first, so on a long motorway
* run it usually answers with exactly one route — while a perfectly good, slower way over
* the B-roads exists and is what somebody opening "other ways" is looking for. Excluding
* a class asks a different question and reliably produces that road.
*
* Only for driving: the foot and bike profiles have no excludable classes, and asking
* anyway earns an `InvalidOptions` for nothing.
*/
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
/** Two lines are the same road if their length and their duration agree closely enough. */
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
}
/**
* Hosts that have already answered "I do not do exclude", by base URL.
*
* Both public hosts TREK ships with reject the parameter outright with HTTP 400: the
* upstream car profile is built without excludable classes, and only a self-hosted OSRM
* built through the MLD pipeline supports it. Without this, every long leg on a default
* install pays two extra requests to be told twice that it cannot have what it asked for,
* against a host that allows one request per second.
*
* The first real attempt is the probe; there is no separate one. Only a 400 is recorded,
* because only a 400 is about the host rather than about this particular question: a 429
* is a rate limit, an abort is the user moving on, and a `NoRoute` means this leg has no
* way round the motorway while the next one might.
*
* Per base URL rather than global, so pointing the instance at an own router that does
* support it starts asking again without a reload.
*/
const excludeUnsupported = new Set<string>()
/**
* One route with a road class left out, or null when the router will not or cannot.
*
* Every failure is a null: a rate limit answers 429, and a leg with no way round the
* motorway answers `NoRoute`. Neither is worth an error on screen — they just mean this
* particular question had no answer.
*/
async function routeExcluding(
coords: string,
profile: 'driving' | 'walking' | 'cycling',
exclude: string,
signal?: AbortSignal,
): Promise<RouteAlternative | null> {
const base = routeBaseFor(profile)
if (excludeUnsupported.has(base)) return null
try {
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
const response = await fetch(url, { signal })
if (!response.ok) {
// 400 is the router saying the parameter itself is not available here, which is
// true of every leg from now on. Anything else is about this request alone.
if (response.status === 400) excludeUnsupported.add(base)
return null
}
const data = await response.json()
const route = data?.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null
if (!route?.geometry?.coordinates?.length) return null
return {
coordinates: route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]),
distance: route.distance,
duration: route.duration,
divergence: null,
}
} catch {
return null
}
}
/**
* The ways of driving one leg, not one day.
*
* Deliberately per leg: OSRM only offers alternatives between exactly two coordinates, so
* asking for a whole day would mean splitting it into one request per leg — which is the
* bundling `splitIntoRuns` exists to avoid, and the reason a road trip appears at once
* instead of trickling in. Asked for a single leg, on demand, it costs one extra request.
*
* `divergence` is the point on each alternative that lies furthest from the default route.
* It is what makes a choice persistable: saving that point as a via forces the router back
* onto this road on every future request, without storing a polyline that would go stale
* with the next OSM update.
*/
export async function calculateAlternatives(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling' = 'driving',
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
): Promise<RouteAlternative[]> {
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
const response = await fetch(url, { signal })
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
if (data.code !== 'Ok' || !Array.isArray(data.routes) || !data.routes.length) return []
const routes: RouteAlternative[] = data.routes.map((r: { geometry: { coordinates: [number, number][] }; distance: number; duration: number }) => ({
coordinates: r.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]),
distance: r.distance,
duration: r.duration,
divergence: null,
}))
// Nothing but the router's own preference came back, which is what it answers on most
// long legs. Rather than reporting "only one sensible way", ask a different question:
// the same drive without the motorway, and failing that without the tolls. Tried one at
// a time and stopped as soon as one lands, so a leg that does have a second road costs
// one extra request rather than three.
//
// Worth knowing before reading further: on the two public hosts TREK ships with, this
// never produces anything. Their car profile is built without excludable classes and
// the parameter comes back as HTTP 400, which is why long legs keep reporting a single
// way to drive them. It works against a self-hosted OSRM built through the MLD
// pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once.
if (routes.length < 2 && profile === 'driving') {
for (const exclude of EXCLUDABLE_CLASSES) {
if (signal?.aborted) break
const detour = await routeExcluding(coords, profile, exclude, signal)
if (detour && !routes.some(r => sameRoad(r, detour))) {
detour.avoids = exclude
routes.push(detour)
break
}
}
}
// The first route is what the router would have given anyway; the others are measured
// against it so each one can be pinned by the point that makes it different.
const [primary, ...rest] = routes
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
return routes
}
/** The point of `line` that lies furthest from `reference`, in plain degrees. */
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null {
if (!line.length || !reference.length) return null
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
// full cross product of two thousand-point lines is not worth the milliseconds.
const step = Math.max(1, Math.floor(reference.length / 200))
let best: { lat: number; lng: number } | null = null
let bestDist = -1
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
const [lat, lng] = line[i]
let nearest = Infinity
for (let j = 0; j < reference.length; j += step) {
const dLat = lat - reference[j][0]
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
const d = dLat * dLat + dLng * dLng
if (d < nearest) nearest = d
}
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
}
return best
}
@@ -1,5 +1,7 @@
import mapboxgl from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
import { registerRtlTextPlugin } from '../rtlText'
/**
* The only module in the client that pulls mapbox-gl in at runtime.
@@ -8,4 +10,8 @@ import 'mapbox-gl/dist/mapbox-gl.css'
* a static import from anywhere in the tree drags 1.8 MB into the parent chunk and
* quietly undoes the split. `npm run check:gl-split` fails on that.
*/
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
// Mapbox's signature is (url, callback, deferred) — see rtlText.ts.
registerRtlTextPlugin(() => mapboxgl.setRTLTextPlugin(rtlTextPluginUrl, null, true))
export default mapboxgl
@@ -1,5 +1,7 @@
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
import { registerRtlTextPlugin } from '../rtlText'
/**
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
@@ -10,4 +12,8 @@ import 'maplibre-gl/dist/maplibre-gl.css'
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
* on the provider (projection, accessToken, aroundCenter).
*/
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
export default maplibregl
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The two lines this fix actually adds. Everything else is a helper: the guard in
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
* working fix from the outside. Deleting either registration, swapping an
* argument or dropping the deferred flag left the whole suite green.
*
* The engines are mocked because importing them for real builds a worker pool
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
*/
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
mapboxSet: vi.fn(),
maplibreSet: vi.fn(),
}));
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
beforeEach(() => {
vi.resetModules();
mapboxSet.mockReset();
maplibreSet.mockReset();
});
describe('the GL engines register the RTL text plugin (#2235)', () => {
it('FE-MAP-RTL-001: importing the mapbox engine registers it deferred, with a null callback', async () => {
await import('./mapbox');
expect(mapboxSet).toHaveBeenCalledTimes(1);
// Mapbox GL 3 takes (url, callback, deferred). The third argument is what
// keeps the payload off the wire until a map paints RTL text; dropping it
// would fetch 133 KB for every user on every map.
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
});
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
await import('./maplibre');
expect(maplibreSet).toHaveBeenCalledTimes(1);
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
// the call stays with each engine instead of going through one wrapper.
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
});
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
mapboxSet.mockImplementation(() => { throw new Error('already registered'); });
const engine = await import('./mapbox');
expect(engine.default).toBeDefined();
});
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
const engine = await import('./maplibre');
expect(engine.default).toBeDefined();
// Give the rejection a tick to go unhandled if nothing caught it.
await new Promise((resolve) => setTimeout(resolve, 0));
});
});
+91 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { makeMarkerDraggable } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
/**
* jsdom has no drag implementation, so these drive the events the browser would
@@ -82,3 +82,93 @@ describe('makeMarkerDraggable', () => {
expect(window.__dragData).toBeNull()
})
})
describe('makePoiDraggable', () => {
afterEach(() => {
window.__dragData = null
document.body.classList.remove('dragging-poi')
})
const dragEvent = (type: string) => {
const e = new Event(type, { bubbles: true, cancelable: true }) as DragEvent & { dataTransfer: DataTransfer }
const store: Record<string, string> = {}
Object.defineProperty(e, 'dataTransfer', {
value: {
setData: (k: string, v: string) => { store[k] = v },
getData: (k: string) => store[k] ?? '',
effectAllowed: '',
dropEffect: '',
},
})
return e
}
it('FE-MAP-POIDRAG-001: a hit carries its OSM id, not a place id — it is not a place yet', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
expect(el.getAttribute('draggable')).toBe('true')
const e = dragEvent('dragstart')
el.dispatchEvent(e)
expect(e.dataTransfer.getData('poiOsmId')).toBe('node:9')
// Mirrored on window because dataTransfer is unreadable during dragover, and the
// map has to know what is coming while it is still being hovered.
expect(window.__dragData).toEqual({ poiOsmId: 'node:9' })
})
it('FE-MAP-POIDRAG-002: the drag marks the body, so the route can light up while it lasts', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
el.dispatchEvent(dragEvent('dragstart'))
expect(document.body.classList.contains('dragging-poi')).toBe(true)
el.dispatchEvent(dragEvent('dragend'))
expect(document.body.classList.contains('dragging-poi')).toBe(false)
expect(window.__dragData).toBeNull()
})
it('FE-MAP-POIDRAG-003: mousedown is swallowed, or the map pans out from under the drag', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
const seen = vi.fn()
document.body.appendChild(el)
document.body.addEventListener('mousedown', seen)
el.dispatchEvent(down)
expect(seen).not.toHaveBeenCalled()
document.body.removeEventListener('mousedown', seen)
el.remove()
})
it('FE-MAP-POIDRAG-004: unwiring leaves the element as it was found', () => {
const el = document.createElement('div')
const off = makePoiDraggable(el, 'node:9')
off()
expect(el.hasAttribute('draggable')).toBe(false)
el.dispatchEvent(dragEvent('dragstart'))
expect(window.__dragData).toBeNull()
})
it('FE-MAP-POIDRAG-005: the id is readable from the event, and from the mirror during a hover', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
const start = dragEvent('dragstart')
el.dispatchEvent(start)
expect(draggedPoiId(start)).toBe('node:9')
// A dragover event carries an unreadable dataTransfer; the mirror answers instead.
const over = new Event('dragover') as DragEvent
expect(draggedPoiId(over)).toBe('node:9')
})
it('FE-MAP-POIDRAG-006: an unrelated drag is not mistaken for a hit', () => {
const over = new Event('dragover') as DragEvent
expect(draggedPoiId(over)).toBeNull()
})
})
+48
View File
@@ -48,3 +48,51 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
el.removeEventListener('mousedown', onMouseDown)
}
}
/**
* Wire a corridor hit up as a drag source, so it can be dropped onto the drive.
*
* The same HTML5 drag as `makeMarkerDraggable`, with the OSM id as payload instead of a
* place id — a corridor hit is not a place yet. The drop target is the map itself rather
* than the drawn line: a route polyline is a real element only in Leaflet (the GL
* renderers draw it into the canvas), and the drop coordinate answers "where on the drive"
* just as well once it is projected onto the routed geometry.
*/
export function makePoiDraggable(el: HTMLElement, osmId: string): () => void {
el.setAttribute('draggable', 'true')
const onDragStart = (e: DragEvent) => {
e.dataTransfer?.setData('poiOsmId', osmId)
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { poiOsmId: osmId }
el.classList.add('marker-dragging')
document.body.classList.add('dragging-poi')
}
const onDragEnd = () => {
window.__dragData = null
el.classList.remove('marker-dragging')
document.body.classList.remove('dragging-poi')
}
// Same reason as above: without this the map pans out from under the pointer as the
// drag begins, and the route is impossible to aim at.
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
el.addEventListener('dragstart', onDragStart)
el.addEventListener('dragend', onDragEnd)
el.addEventListener('mousedown', onMouseDown)
return () => {
el.removeAttribute('draggable')
el.removeEventListener('dragstart', onDragStart)
el.removeEventListener('dragend', onDragEnd)
el.removeEventListener('mousedown', onMouseDown)
}
}
/** The OSM id being dragged, when a corridor hit is in flight. */
export function draggedPoiId(e: DragEvent): string | null {
const fromTransfer = e.dataTransfer?.getData('poiOsmId')
if (fromTransfer) return fromTransfer
// dataTransfer is unreadable during dragover, so the window mirror answers instead.
return window.__dragData?.poiOsmId ?? null
}
+34 -3
View File
@@ -1,4 +1,8 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
// The road-trip kinds take their colour from the one place that defines it, so the
// search result, the map pin and the road-trip rail cannot drift apart.
import { SERVICE_COLORS } from '../Roadtrip/roadtripModel'
import { CORRIDOR_CATEGORIES, HOTEL_COLOR } from '../Roadtrip/stopKinds'
// The POI categories shown in the map "explore" pill. The `key` is the contract
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
@@ -15,15 +19,27 @@ export const POI_CATEGORIES: PoiCategory[] = [
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
// The colour lives with the road-trip categories, because the corridor search offers
// the same one and two definitions of a blue is how they drift apart.
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR },
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
]
/**
* What a drive needs. Kept out of POI_CATEGORIES on purpose: these are not offered by
* the explore pill, they are what the road trip corridor searches for. They are in the
* lookup below all the same, so a hit drawn on the map gets its own icon and colour
* instead of the nameless grey dot an unknown category falls back to.
*/
export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
)
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
POI_CATEGORIES.map(c => [c.key, c]),
[...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c]),
)
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
@@ -34,10 +50,25 @@ export interface Poi {
lng: number
category: string
poi_type: string
/** Brand name and its Wikidata id, when OSM carries them (road categories mostly do). */
brand?: string | null
brand_wikidata?: string | null
address: string | null
website: string | null
phone: string | null
opening_hours: string | null
cuisine: string | null
/**
* What a charging station offers, when it is one and OSM says so.
*
* Optional twice over: only charging hits carry it at all, and OSM knows a socket type
* for roughly a third of them, a capacity for about seven in ten. "Not stated" is its
* own answer here and must not be read as a no.
*/
charging?: {
sockets: { type: string; count: number | null; kw: number | null }[]
capacity: number | null
fee: boolean | null
} | null
source: 'openstreetmap'
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest'
import { registerRtlTextPlugin } from './rtlText'
describe('registerRtlTextPlugin', () => {
it('runs the engine-supplied registration', () => {
const register = vi.fn()
expect(registerRtlTextPlugin(register)).toBe(true)
expect(register).toHaveBeenCalledTimes(1)
})
// Registering twice is how the engines report it: Mapbox throws.
it('never lets a refused registration break map creation', () => {
const register = vi.fn(() => {
throw new Error('setRTLTextPlugin cannot be called multiple times.')
})
expect(() => registerRtlTextPlugin(register)).not.toThrow()
expect(registerRtlTextPlugin(register)).toBe(false)
})
// MapLibre reports the same by rejecting, and an unhandled rejection would
// reach the console for something the user cannot act on.
it('swallows a rejected registration promise', async () => {
const onUnhandled = vi.fn()
process.on('unhandledRejection', onUnhandled)
expect(registerRtlTextPlugin(() => Promise.reject(new Error('404')))).toBe(true)
await new Promise(resolve => setTimeout(resolve, 0))
process.off('unhandledRejection', onUnhandled)
expect(onUnhandled).not.toHaveBeenCalled()
})
it('leaves a resolved promise alone', async () => {
expect(registerRtlTextPlugin(() => Promise.resolve())).toBe(true)
})
})
+48
View File
@@ -0,0 +1,48 @@
/**
* Arabic, Hebrew, Persian and Urdu labels need two things a GL engine does not do
* on its own: the bidirectional algorithm, and Arabic contextual shaping (a letter
* is drawn differently depending on its neighbours). Without them the engine lays
* the codepoints out one by one, left to right, in isolated forms — so القاهرة
* comes out as ة ر ه ا ق ل ا, unjoined and reversed. Raster basemaps never showed
* this because their labels are drawn server-side, which is why it only surfaced
* once the default basemap became a vector style.
*
* Both engines expose the fix as an optional plugin and neither loads it by
* default. Registering is global to the engine, so it happens once per engine
* module rather than at each map's construction site.
*
* The call itself stays with the caller: this is one of the places the two engines
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
* module passes its own invocation rather than a shared wrapper guessing an arity.
*
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
* build its worker pool, which is an eager side effect for a module that is
* imported for its default export, and throws outright under jsdom. Registering
* twice is reported — by a throw or a rejected promise depending on the engine —
* and both are handled below, so the probe bought nothing.
*/
/**
* Runs a GL engine's RTL-plugin registration, swallowing the ways it can fail.
*
* Callers register lazily, so the payload is only fetched once a map actually
* paints RTL text and a user who never leaves Latin script downloads nothing.
*
* @returns whether the registration call completed without throwing.
*/
export function registerRtlTextPlugin(register: () => void | Promise<unknown>): boolean {
try {
const pending = register()
// MapLibre reports a failed or duplicate registration by rejecting; Mapbox
// routes the same through its callback. Neither is worth surfacing: a map
// with unshaped labels still renders, and an unhandled rejection would not
// give the user anything to act on.
if (pending instanceof Promise) pending.catch(() => {})
return true
} catch {
// Already registered, or the engine refused outright. Registering the plugin
// must never be able to break map creation.
return false
}
}
@@ -0,0 +1,68 @@
import { renderHook } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useStableVias } from './viaMarkerState'
import type { RoadtripVia } from '@trek/shared'
/**
* The list both map renderers rebuild their via handles from.
*
* Its identity is the whole contract: the GL renderer replaces the DOM element when this
* changes, and a pointerdown belongs to the element it happened on. A list that changed
* on every render meant a freshly placed via lost its first drag to the re-route landing
* underneath it.
*/
const via = (over: Partial<RoadtripVia> & { id: number }): RoadtripVia => ({
day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1, ...over,
})
describe('useStableVias', () => {
it('FE-VIASTATE-001: the same vias in a fresh object are the same list', () => {
// Exactly what a reload hands down: identical rows, a new object every time.
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
)
const first = result.current
rerender({ byDay: { 1: [via({ id: 5 })] } })
expect(result.current).toBe(first)
})
it('FE-VIASTATE-002: a via that moved is a new list, so the handle is redrawn where it now is', () => {
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
)
const first = result.current
rerender({ byDay: { 1: [via({ id: 5, lat: 48.9 })] } })
expect(result.current).not.toBe(first)
expect(result.current[0].lat).toBe(48.9)
})
it('FE-VIASTATE-003: adding or removing one is a new list too', () => {
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
)
const first = result.current
rerender({ byDay: { 1: [via({ id: 5 }), via({ id: 6, lat: 48.4 })] } })
expect(result.current).not.toBe(first)
expect(result.current).toHaveLength(2)
})
it('FE-VIASTATE-004: every day of the trip is in one list, and no vias is a stable empty one', () => {
const { result, rerender } = renderHook(
({ byDay }) => useStableVias(byDay),
{ initialProps: { byDay: { 1: [via({ id: 5 })], 2: [via({ id: 6, day_id: 2 })] } as Record<number, RoadtripVia[]> | undefined },
})
expect(result.current.map(v => v.id)).toEqual([5, 6])
rerender({ byDay: undefined })
const empty = result.current
expect(empty).toEqual([])
rerender({ byDay: {} })
expect(result.current).toBe(empty)
})
})
@@ -0,0 +1,30 @@
import { useRef } from 'react'
import type { RoadtripVia } from '@trek/shared'
/**
* The trip's via points as an array that only changes when a via does.
*
* Both map renderers rebuild their via handles whenever this list changes, and the GL one
* replaces the DOM element outright. The list it was given changed on every render: the
* hook that loads vias returns a fresh object each time, and a fresh object arrives again
* on every reload even when the rows came back identical.
*
* That made dragging a freshly placed via fail on the first try. Placing one triggers a
* re-route, the re-route lands about a second later, the render it causes replaced the
* handle under the pointer — and a pointerdown belongs to the element it happened on, so
* the drag went to an element that no longer existed. The second attempt worked because
* by then nothing was pending. An older via never showed it: nothing was re-routing.
*
* Keyed by id and position because those are the only fields a handle is built from. A
* via that genuinely moved gets a new list, which is what redraws it where it now is.
*/
export function useStableVias(viasByDay: Record<number, RoadtripVia[]> | undefined): RoadtripVia[] {
const flat = Object.values(viasByDay ?? {}).flat()
const key = flat.map(v => `${v.id}@${v.lat},${v.lng}`).join('|')
// Read and written during render on purpose: this is a cache of the argument, not
// state — it never triggers a render of its own, and it has to be settled before the
// effects that consume the list run.
const held = useRef<{ key: string; list: RoadtripVia[] }>({ key: '', list: [] })
if (held.current.key !== key) held.current = { key, list: flat }
return held.current.list
}
+18 -1
View File
@@ -223,7 +223,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
const allCostEntries: MoneyEntry[] = Object.values(assignments)
.flatMap(a => a)
.map(a => ({ amount: Number(a.place?.price) || 0, currency: a.place?.currency || tripCur }))
const needsFx = allCostEntries.some(e => e.amount > 0 && e.currency.toUpperCase() !== tripCur)
const needsFx = allCostEntries.some(e => e.amount !== 0 && e.currency.toUpperCase() !== tripCur)
const fxRates = needsFx ? await fetchExchangeRates(tripCur) : null
const totalCostLabel = formatMoneySum(allCostEntries, tripCur, loc || 'en', fxRates)
@@ -353,6 +353,22 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
}
}
else if (r.type === 'car') {
// A rental with stops is a drive, and the printout is what people take
// into the car (#1797). Without this the route reads as pick-up and return
// with everything in between missing.
//
// Consecutive repeats collapse, and a chain that names one place only is
// dropped: the ordinary rental is picked up and returned at the same desk,
// which satisfies "two endpoints" and would otherwise print the counter's
// name twice with an arrow between. A genuine round trip keeps both ends,
// because Dresden sits between them.
const stops = (r.endpoints || []).slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map(e => e.name)
.filter((name, i, all) => name && name !== all[i - 1])
subtitle = new Set(stops).size >= 2 ? stops.join(' → ') : ''
}
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
@@ -442,6 +458,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''}
</div>
</div>`
}).join('')
@@ -1,18 +1,22 @@
import { X, Plus } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
import { bagFillPct, countsTowardsMyLoad, itemWeight } from './packingListPanel.helpers'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from './packingListPanel.helpers'
import { BagCard } from './PackingListPanelBagCard'
export function BagModal(S: PackingState) {
const {
setShowBagModal, t, bags, items, tripId, tripMembers, canEdit, currentUserId, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag,
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, unassignedWeightGrams, serverWeightsFresh,
} = S
// These numbers describe what you are carrying. An item someone shared with you stays
// in your list, but they are the one bringing it, so it is not your weight (#1767).
// The ITEM LISTS still describe what you are carrying — an item someone shared
// with you stays in your list, but they are the one bringing it (#1767).
const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId))
// The WEIGHTS no longer do. A bag's load is the bag's, whoever packed it and
// whether or not you may see the items, so it comes from the server (#2191).
const bagWeightOf = (bag: typeof bags[number]) =>
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
// Reference for bags without a limit of their own — computed once instead of per bag.
const heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
role="button" tabIndex={0} aria-label={t('common.close')}
@@ -30,7 +34,7 @@ export function BagModal(S: PackingState) {
{bags.map(bag => {
const bagItems = myItems.filter(i => i.bag_id === bag.id)
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
const totalWeight = bagWeightOf(bag)
const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight)
return (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
@@ -40,8 +44,11 @@ export function BagModal(S: PackingState) {
{/* Unassigned */}
{(() => {
const unassigned = myItems.filter(i => !i.bag_id)
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
if (unassigned.length === 0) return null
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
// Shown whenever there is weight to account for, even with no visible
// items: the grand total counts it, and a total nothing adds up to is
// the confusion this issue was about (#2191).
if (unassigned.length === 0 && unassignedWeight === 0) return null
return (
<div style={{ marginBottom: 16, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
@@ -60,7 +67,13 @@ export function BagModal(S: PackingState) {
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = myItems.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
<span>{(() => {
// Same rule as the rows above it: a grand total mixing true bag
// weights with a per-viewer remainder would be worse than either.
const w = bags.reduce((s, b) => s + bagWeightOf(b), 0)
+ unassignedTotalWeight(unassignedWeightGrams, myItems.filter(i => !i.bag_id), serverWeightsFresh)
return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g`
})()}</span>
</div>
</div>
@@ -1,18 +1,21 @@
import { Plus } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
import { bagFillPct, countsTowardsMyLoad, itemWeight } from './packingListPanel.helpers'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from './packingListPanel.helpers'
import { BagCard } from './PackingListPanelBagCard'
export function BagSidebar(S: PackingState) {
const {
t, bags, items, tripId, tripMembers, canEdit, currentUserId, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag,
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, unassignedWeightGrams, serverWeightsFresh,
} = S
// These numbers describe what you are carrying. An item someone shared with you stays
// in your list, but they are the one bringing it, so it is not your weight (#1767).
// The ITEM LISTS still describe what you are carrying — an item someone shared
// with you stays in your list, but they are the one bringing it (#1767).
const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId))
// The WEIGHTS no longer do: a bag's load is the bag's, whoever packed it (#2191).
const bagWeightOf = (bag: typeof bags[number]) =>
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
// Reference for bags without a limit of their own — computed once instead of per bag.
const heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
return (
<div className="hidden xl:block" style={{ width: 260, marginLeft: 16, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
@@ -21,7 +24,7 @@ export function BagSidebar(S: PackingState) {
{bags.map(bag => {
const bagItems = myItems.filter(i => i.bag_id === bag.id)
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
const totalWeight = bagWeightOf(bag)
const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight)
return (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
@@ -31,8 +34,10 @@ export function BagSidebar(S: PackingState) {
{/* Unassigned */}
{(() => {
const unassigned = myItems.filter(i => !i.bag_id)
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
if (unassigned.length === 0) return null
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
// Shown whenever there is weight to account for, even with no visible
// items — the grand total counts it either way (#2191).
if (unassigned.length === 0 && unassignedWeight === 0) return null
return (
<div style={{ marginBottom: 14, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
@@ -51,7 +56,12 @@ export function BagSidebar(S: PackingState) {
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = myItems.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
<span>{(() => {
// Same rule as the rows above it (#2191).
const w = bags.reduce((s, b) => s + bagWeightOf(b), 0)
+ unassignedTotalWeight(unassignedWeightGrams, myItems.filter(i => !i.bag_id), serverWeightsFresh)
return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g`
})()}</span>
</div>
</div>
@@ -0,0 +1,91 @@
// FE-COMP-BAGWEIGHT-001 to FE-COMP-BAGWEIGHT-006
/**
* What the bag panels actually print (#2191).
*
* The numbers used to be summed in the browser over the privacy-filtered item
* list, so a bag shared with someone whose personal items you cannot see read
* lighter than it was — and that figure is what gets measured against an
* airline's weight limit. The server now sends the real total; these tests pin
* that the panels show it, that the "no bag" pile and the grand total follow
* the same rule, and that offline they fall back to the honest partial sum
* rather than a frozen absolute one.
*/
import { describe, it, expect, vi } from 'vitest'
import { render, screen, within } from '../../../tests/helpers/render'
import { BagSidebar } from './PackingListPanelBagSidebar'
import { BagModal } from './PackingListPanelBagModal'
import type { PackingState } from './usePackingListPanel'
/** My own 300 g item; the buddy's 700 g personal item never reaches this client. */
const ITEMS = [
{ id: 1, name: 'Stove', weight_grams: 300, quantity: 1, bag_id: 10, is_private: 0, owner_id: 1, checked: 0, category: 'Kitchen' },
{ id: 2, name: 'Sandwich', weight_grams: 150, quantity: 1, bag_id: null, is_private: 0, owner_id: 1, checked: 0, category: 'Food' },
]
function buildState(over: Partial<PackingState> = {}): PackingState {
return {
t: (k: string) => k,
bags: [{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, weight_limit_grams: null, members: [], total_weight_grams: 1000 }],
items: ITEMS,
tripId: 1,
tripMembers: [],
canEdit: false,
currentUserId: 1,
unassignedWeightGrams: 500,
serverWeightsFresh: true,
handleDeleteBag: vi.fn(),
handleUpdateBag: vi.fn(),
handleSetBagMembers: vi.fn(),
handleCreateBag: vi.fn(),
setShowBagModal: vi.fn(),
showAddBag: false,
setShowAddBag: vi.fn(),
newBagName: '',
setNewBagName: vi.fn(),
...over,
} as unknown as PackingState
}
describe('bag weights on the packing panels (#2191)', () => {
it('FE-COMP-BAGWEIGHT-001: the sidebar shows the server total, not the sum of what you can see', () => {
// Locally visible: 300 g. Actually in the bag: 1000 g.
render(<BagSidebar {...buildState()} />)
expect(screen.getByText('1.0 kg')).toBeInTheDocument()
expect(screen.queryByText('300 g')).not.toBeInTheDocument()
})
it('FE-COMP-BAGWEIGHT-002: the grand total adds the bags and the unassigned pile', () => {
// 1000 g in the bag + 500 g loose.
const { container } = render(<BagSidebar {...buildState()} />)
const total = within(container).getByText('packing.totalWeight').parentElement as HTMLElement
expect(total).toHaveTextContent('1.5 kg')
})
it('FE-COMP-BAGWEIGHT-003: the modal follows the same rule as the sidebar', () => {
render(<BagModal {...buildState()} />)
expect(screen.getByText('1.0 kg')).toBeInTheDocument()
})
it('FE-COMP-BAGWEIGHT-004: offline it falls back to summing what is visible', () => {
// The server totals are frozen at the last online read and blind to the
// mutation queue, so an honest partial number beats a stale absolute one.
render(<BagSidebar {...buildState({ serverWeightsFresh: false })} />)
expect(screen.getByText('300 g')).toBeInTheDocument()
expect(screen.queryByText('1.0 kg')).not.toBeInTheDocument()
})
it('FE-COMP-BAGWEIGHT-005: a bag from before the field falls back to the local sum', () => {
const bags = [{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, weight_limit_grams: null, members: [] }]
render(<BagSidebar {...buildState({ bags: bags as never, unassignedWeightGrams: null })} />)
expect(screen.getByText('300 g')).toBeInTheDocument()
})
it('FE-COMP-BAGWEIGHT-006: the unassigned row appears for weight with no visible items', () => {
// Someone else's personal item is loose in the trip: the grand total counts
// it, so a row has to account for it or the total adds up to nothing.
render(<BagSidebar {...buildState({ items: [ITEMS[0]] as never, unassignedWeightGrams: 400 })} />)
expect(screen.getByText('packing.noBag')).toBeInTheDocument()
expect(screen.getByText('400 g')).toBeInTheDocument()
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { katColor, itemWeight, bagFillPct, countsTowardsMyLoad, parseCsvLine, parseImportLines } from './packingListPanel.helpers'
import { katColor, itemWeight, bagFillPct, bagTotalWeight, countsTowardsMyLoad, parseCsvLine, parseImportLines, unassignedTotalWeight } from './packingListPanel.helpers'
import { KAT_COLORS } from './packingListPanel.constants'
describe('packingListPanel.helpers', () => {
@@ -128,4 +128,29 @@ describe('packingListPanel.helpers', () => {
expect(rows[0].name).toBe('Passport')
})
})
describe('bagTotalWeight / unassignedTotalWeight (#2191)', () => {
it('prefers the server total over anything summable locally', () => {
// The whole point: the local list is privacy-filtered, so it can only ever
// be the part of the bag this viewer is allowed to see.
expect(bagTotalWeight({ total_weight_grams: 1000 }, [{ weight_grams: 800, quantity: 1 }])).toBe(1000)
})
it('keeps a server-reported zero instead of falling back to the local sum', () => {
// An empty bag really weighs 0; only an ABSENT field means "not told".
expect(bagTotalWeight({ total_weight_grams: 0 }, [{ weight_grams: 800, quantity: 1 }])).toBe(0)
})
it('falls back to the local sum for a bag cached before the field existed', () => {
expect(bagTotalWeight({}, [{ weight_grams: 250, quantity: 3 }, { weight_grams: 50 }])).toBe(800)
expect(bagTotalWeight({ total_weight_grams: null }, [{ weight_grams: 120 }])).toBe(120)
})
it('applies the same rule to the unassigned pile', () => {
expect(unassignedTotalWeight(150, [{ weight_grams: 900 }])).toBe(150)
expect(unassignedTotalWeight(0, [{ weight_grams: 900 }])).toBe(0)
expect(unassignedTotalWeight(null, [{ weight_grams: 900 }])).toBe(900)
expect(unassignedTotalWeight(undefined, [])).toBe(0)
})
})
})
@@ -30,6 +30,43 @@ export const countsTowardsMyLoad = (
return i.owner_id == null || i.owner_id === currentUserId
}
/**
* What a bag weighs, as shown (#2191).
*
* The server sums every member's items — including the private ones this viewer
* may not see — and sends the figure on the bag itself. Adding it up locally
* could only ever produce the part of the bag the viewer is allowed to look at,
* which is the wrong number to measure against an airline's weight limit.
*
* The local sum is the fallback in two cases: a bag that reached the client
* without the field (a row cached before #2191), and `serverFresh === false`,
* which the surfaces pass while offline — bags have no repo and no Dexie table,
* so their totals are frozen at the last online read and blind to whatever the
* mutation queue is holding. A stale absolute number measured against an
* airline limit is worse than an honest partial one.
*
* Note the explicit null check rather than `??` on a falsy value: an empty bag
* legitimately weighs 0 and must not fall through to the local sum.
*/
export const bagTotalWeight = (
bag: { total_weight_grams?: number | null },
visibleItems: { weight_grams?: number | null; quantity?: number | null }[],
serverFresh = true,
): number =>
serverFresh && bag.total_weight_grams != null
? bag.total_weight_grams
: visibleItems.reduce((sum, i) => sum + itemWeight(i), 0)
/** The same rule for the pile that is in no bag (#2191). */
export const unassignedTotalWeight = (
serverTotal: number | null | undefined,
visibleItems: { weight_grams?: number | null; quantity?: number | null }[],
serverFresh = true,
): number =>
serverFresh && serverTotal != null
? serverTotal
: visibleItems.reduce((sum, i) => sum + itemWeight(i), 0)
/**
* How full a bag's bar reads. A bag with a weight limit is measured against that limit —
* that is the number an airline cares about. Without one there is nothing absolute to
@@ -0,0 +1,164 @@
// FE-COMP-BAGPING-001 to FE-COMP-BAGPING-009
/**
* The bag-totals ping listener (#2191).
*
* Bag weights are summed server-side across every member, so the event that
* says they moved cannot carry the new numbers without leaking what produced
* them. That makes every ping a refetch, and the server deliberately sends it
* to the whole room including the writer — so the coalescing and the in-flight
* guard here are what stop a busy trip turning one write into a storm of
* listBags requests on every connected client.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useBagTotalsPing } from './useBagTotalsPing'
import { setForcedOffline, _resetNetworkMode } from '../../sync/networkMode'
const listeners = new Set<(e: Record<string, unknown>) => void>()
vi.mock('../../api/websocket', () => ({
addListener: (fn: (e: Record<string, unknown>) => void) => { listeners.add(fn) },
removeListener: (fn: (e: Record<string, unknown>) => void) => { listeners.delete(fn) },
}))
/** Deliver an event the way the websocket layer would. */
function emit(type: string): void {
for (const fn of [...listeners]) fn({ type })
}
/** Past the 250ms coalescing window. */
async function flushCoalesce(): Promise<void> {
await act(async () => { await vi.advanceTimersByTimeAsync(300) })
}
beforeEach(() => {
listeners.clear()
_resetNetworkMode()
vi.useFakeTimers()
})
afterEach(() => {
setForcedOffline(false)
vi.useRealTimers()
})
describe('useBagTotalsPing', () => {
it('FE-COMP-BAGPING-001: reloads once a ping arrives', async () => {
const reload = vi.fn(async () => {})
renderHook(() => useBagTotalsPing(true, reload))
expect(reload).not.toHaveBeenCalled()
act(() => emit('packing:bag-totals'))
await flushCoalesce()
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-COMP-BAGPING-002: folds a burst of pings into a single refetch', async () => {
// Clearing twelve checked items issues twelve deletes; an import writes a
// whole list. Each one pings the room.
const reload = vi.fn(async () => {})
renderHook(() => useBagTotalsPing(true, reload))
act(() => { for (let i = 0; i < 12; i++) emit('packing:bag-totals') })
await flushCoalesce()
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-COMP-BAGPING-003: never overlaps two refetches, and serves a ping that arrived mid-flight', async () => {
let release: (() => void) | undefined
const reload = vi.fn(() => new Promise<void>(resolve => { release = resolve }))
renderHook(() => useBagTotalsPing(true, reload))
act(() => emit('packing:bag-totals'))
await flushCoalesce()
expect(reload).toHaveBeenCalledTimes(1)
// A second burst while the first request is still open must not start a
// parallel one...
act(() => emit('packing:bag-totals'))
await flushCoalesce()
expect(reload).toHaveBeenCalledTimes(1)
// ...but it must not be dropped either: the totals moved again.
await act(async () => { release?.(); await Promise.resolve() })
expect(reload).toHaveBeenCalledTimes(2)
})
it('FE-COMP-BAGPING-004: ignores every other event type', async () => {
const reload = vi.fn(async () => {})
renderHook(() => useBagTotalsPing(true, reload))
act(() => { emit('packing:created'); emit('packing:updated'); emit('place:created') })
await flushCoalesce()
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-BAGPING-005: subscribes to nothing while bag tracking is off', async () => {
const reload = vi.fn(async () => {})
renderHook(() => useBagTotalsPing(false, reload))
expect(listeners.size).toBe(0)
act(() => emit('packing:bag-totals'))
await flushCoalesce()
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-BAGPING-006: unsubscribes on unmount and does not reload afterwards', async () => {
const reload = vi.fn(async () => {})
const { unmount } = renderHook(() => useBagTotalsPing(true, reload))
expect(listeners.size).toBe(1)
// A ping already collecting when the panel closes must not land on a
// component that is gone.
act(() => emit('packing:bag-totals'))
unmount()
await flushCoalesce()
expect(listeners.size).toBe(0)
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-BAGPING-007: reloads when the socket re-joins the room', async () => {
// A ping sent while the socket was down is gone for good (a container
// restart, a tunnel that dropped), and bags have no offline cache to catch
// up from. The re-join is the only notice this client gets.
const reload = vi.fn(async () => {})
renderHook(() => useBagTotalsPing(true, reload))
act(() => emit('joined'))
await flushCoalesce()
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-COMP-BAGPING-008: reloads on the way back online, not on the way out', async () => {
const reload = vi.fn(async () => {})
renderHook(() => useBagTotalsPing(true, reload))
act(() => setForcedOffline(true))
await flushCoalesce()
expect(reload).not.toHaveBeenCalled()
// Going online is where the surfaces stop summing locally and show the
// server totals again, so those totals had better not be the pre-outage ones.
act(() => setForcedOffline(false))
await flushCoalesce()
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-COMP-BAGPING-009: stops watching the network mode after unmount', async () => {
const reload = vi.fn(async () => {})
const { unmount } = renderHook(() => useBagTotalsPing(true, reload))
act(() => setForcedOffline(true))
unmount()
act(() => setForcedOffline(false))
await flushCoalesce()
expect(reload).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,94 @@
import { useEffect, useRef } from 'react'
import { addListener, removeListener } from '../../api/websocket'
import { isEffectivelyOnline, onNetworkModeChange } from '../../sync/networkMode'
/**
* How long pings are collected before one refetch goes out.
*
* Long enough to fold a burst into a single request — clearing twelve checked
* items issues twelve deletes, and an import writes a whole list — short enough
* that a single edit still reads as instant.
*/
const COALESCE_MS = 250
/**
* Re-read the bag weights when the server says they moved (#2191).
*
* The ping is content-free by design: totals are summed across every member,
* including people whose items this client may not see, so the event cannot
* carry the new numbers without leaking what produced them. That makes every
* ping a refetch, which is why this coalesces bursts and never lets two
* requests overlap — the server pings the whole room, the originating socket
* included, so a busy trip would otherwise have every client refetching once
* per written item.
*
* A ping sent while this client is disconnected is gone for good, and bags have
* no offline cache to sync back, so the two moments where that can have happened
* count as pings of their own: the room re-join after a reconnect, and the
* return to online.
*
* `reload` may change identity every render; the latest one is always used.
*/
export function useBagTotalsPing(enabled: boolean, reload: () => Promise<void>): void {
const reloadRef = useRef(reload)
reloadRef.current = reload
useEffect(() => {
if (!enabled) return
let timer: ReturnType<typeof setTimeout> | null = null
let inFlight = false
/** A ping that arrived while a refetch was running: serve it once that lands. */
let pendingAfterFlight = false
let cancelled = false
const run = (): void => {
if (cancelled) return
if (inFlight) {
pendingAfterFlight = true
return
}
inFlight = true
void reloadRef.current().finally(() => {
inFlight = false
if (pendingAfterFlight && !cancelled) {
pendingAfterFlight = false
run()
}
})
}
const schedule = (): void => {
if (timer) return // already collecting this burst
timer = setTimeout(() => {
timer = null
run()
}, COALESCE_MS)
}
const handler = (event: Record<string, unknown>) => {
// 'joined' is the room re-join the socket sends on every reconnect, so it
// is also the moment the totals on screen are the ones from before the
// drop: whatever moved them in the meantime pinged into a closed socket.
if (event.type !== 'packing:bag-totals' && event.type !== 'joined') return
schedule()
}
let wasOnline = isEffectivelyOnline()
const onMode = (): void => {
const online = isEffectivelyOnline()
// Only the way back matters: that is when the surfaces stop summing what
// this client can see and go back to trusting the server numbers.
if (online && !wasOnline) schedule()
wasOnline = online
}
addListener(handler)
const unsubscribeMode = onNetworkModeChange(onMode)
return () => {
cancelled = true
removeListener(handler)
unsubscribeMode()
if (timer) clearTimeout(timer)
}
}, [enabled])
}
@@ -1,4 +1,4 @@
import { useState, useMemo, useRef, useEffect } from 'react'
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import type { ChangeEvent } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
@@ -7,6 +7,8 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { packingApi, tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useBagTotalsPing } from './useBagTotalsPing'
import type { PackingItem, PackingBag } from '../../types'
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { parseImportLines } from './packingListPanel.helpers'
@@ -210,6 +212,8 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const addonsLoaded = useAddonStore(s => s.loaded)
const loadAddons = useAddonStore(s => s.loadAddons)
const [bags, setBags] = useState<PackingBag[]>([])
/** Server-summed weight of everything in no bag (#2191); null until the first load. */
const [unassignedWeightGrams, setUnassignedWeightGrams] = useState<number | null>(null)
const [newBagName, setNewBagName] = useState('')
const [showAddBag, setShowAddBag] = useState(false)
const [showBagModal, setShowBagModal] = useState(false)
@@ -218,10 +222,35 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
if (!addonsLoaded) loadAddons()
}, [addonsLoaded, loadAddons])
useEffect(() => {
if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
const reloadBags = useCallback(async () => {
if (!bagTrackingEnabled) return
try {
const r = await packingApi.listBags(tripId)
setBags(r.bags || [])
setUnassignedWeightGrams(r.unassigned_weight_grams ?? null)
} catch {
// Offline or a failed read: the surfaces fall back to the local sum
// (see `serverWeightsFresh` below), so there is nothing to roll back.
}
}, [tripId, bagTrackingEnabled])
useEffect(() => { void reloadBags() }, [reloadBags])
// Bag weights are summed server-side across every member (#2191), so an item
// this viewer may not even see still moves them. The item events cannot carry
// that — a private item is delivered only to its owner, which is the very rule
// that made the totals wrong — so the server pings the room content-free and
// we re-read the numbers.
useBagTotalsPing(bagTrackingEnabled, reloadBags)
// Bags are not part of the offline cache (no repo, no Dexie table), so while
// offline the server totals are frozen at the last online read and cannot see
// the optimistic item writes the mutation queue is holding. A stale absolute
// number measured against an airline limit is worse than an honest partial
// one, so offline the surfaces sum what they can see instead (#2191).
const { offline } = useNetworkMode()
const serverWeightsFresh = !offline
const handleCreateBag = async () => {
if (!newBagName.trim()) return
try {
@@ -373,7 +402,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
showSaveTemplate, setShowSaveTemplate, saveTemplateName, setSaveTemplateName,
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { MapPin } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
interface Props {
value: string
@@ -16,6 +17,8 @@ interface Props {
// is never lost, and picking a suggestion just replaces the text (#1496).
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
const { t, locale } = useTranslation()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias()
const [open, setOpen] = useState(false)
const [results, setResults] = useState<any[]>([])
const [highlight, setHighlight] = useState(-1)
@@ -47,7 +50,7 @@ export default function AddressInput({ value, onChange, placeholder, className }
const myReq = ++reqIdRef.current
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale)
const data = await mapsApi.search(trimmed, locale, locationBias)
if (myReq !== reqIdRef.current) return
setResults(data.places || [])
setHighlight(-1)
@@ -56,9 +56,11 @@ interface DayDetailPanelProps {
mobile?: boolean
/** Rename the day from here — the sidebar pencil moved to the transit search (#1065). */
onUpdateDayTitle?: (dayId: number, title: string) => void
/** Name of the place the day's weather is anchored to — captioned above the forecast (#2167). */
weatherPlaceName?: string | null
}
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle }: DayDetailPanelProps) {
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle, weatherPlaceName = null }: DayDetailPanelProps) {
const { t, language, locale } = useTranslation()
const can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
@@ -211,6 +213,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
</div>
) : weather ? (
<div>
{/* Which place the forecast is for — on a roadtrip "the day's weather"
is ambiguous without it (#2167). */}
{weatherPlaceName && (
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
{t('day.weatherFor', { name: weatherPlaceName })}
</div>
)}
{/* Summary row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
<div style={{ width: 40, height: 40, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -87,7 +87,9 @@ vi.mock('../../hooks/useDayNotes', () => ({
}))
vi.mock('../Weather/WeatherWidget', () => ({
default: () => <span data-testid="weather-widget" />,
default: (props: { locationName?: string | null }) => (
<span data-testid="weather-widget" data-location={props.locationName ?? ''} />
),
}))
// A stable toast object so tests can assert on the messages the sidebar raises.
@@ -405,6 +407,14 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText(/10:00/)).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-012b: the day-specific assignment note shows as a caption line in the row (#2163)', () => {
const place = buildPlace({ name: 'Louvre Museum' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place, notes: 'Book the 10:00 timed entry' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />)
expect(screen.getByText('Book the 10:00 timed entry')).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-013: clicking a place calls onPlaceClick', async () => {
const user = userEvent.setup()
const place = buildPlace({ id: 42, name: 'Louvre Museum' })
@@ -1378,39 +1388,90 @@ describe('DayPlanSidebar', () => {
expect(onDeletePlace).toHaveBeenCalledWith(42)
})
// ── Note card edit/delete buttons ─────────────────────────────────────
// ── Note card editing (#2249) ─────────────────────────
it('FE-PLANNER-DAYPLAN-064: note card edit button calls openEditNote', async () => {
it('FE-PLANNER-DAYPLAN-064: clicking a note row opens its edit modal', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
mockDayNotesState.dayNotes = { '10': [note] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
// Find note edit button (Pencil in note-edit-buttons)
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
if (noteEditBtns.length > 0) {
await user.click(noteEditBtns[0] as HTMLElement)
expect(mockDayNotesState.openEditNote).toHaveBeenCalled()
}
await user.click(cardRow(screen.getByText('My note')))
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
})
it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => {
// The bug: an absolutely-positioned pencil/trash pill sat on the note's own
// reorder chevrons, and a coarse-pointer `opacity: 1 !important` in index.css
// made it permanent on tablets. Nothing may float over that column again.
it('FE-PLANNER-DAYPLAN-064b: the note row carries no floating action pill over its reorder buttons', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const row = cardRow(screen.getByText('My note'))
expect(document.querySelector('.note-edit-buttons')).toBeNull()
expect(row.querySelectorAll('[style*="position: absolute"]')).toHaveLength(0)
expect(row.querySelectorAll('.reorder-buttons button')).toHaveLength(2)
// Positional, not just by class: the reorder column owns the row's right edge.
expect(row.lastElementChild).toHaveClass('reorder-buttons')
})
it('FE-PLANNER-DAYPLAN-064e: the keyboard reaches the note and opens it with Enter', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const row = cardRow(screen.getByText('My note'))
expect(row).toHaveAttribute('tabindex', '0')
// A press-scale on a draggable row fights the drag, so the row opts out (#2158).
expect(row).toHaveAttribute('data-no-press')
fireEvent.keyDown(row, { key: 'Enter' })
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
})
it('FE-PLANNER-DAYPLAN-064c: a link inside the note body opens the link, not the edit modal', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
mockDayNotesState.dayNotes = { '10': [note] }
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note', time: '[docs](https://example.com)' })] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
// Find note delete button (Trash2 in note-edit-buttons)
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
if (noteEditBtns.length > 1) {
await user.click(noteEditBtns[1] as HTMLElement)
// Clicking delete opens a confirmation dialog rather than deleting immediately.
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
expect(screen.getByText('Delete note?')).toBeInTheDocument()
// Confirming triggers the actual delete.
await user.click(screen.getByRole('button', { name: /^delete$/i }))
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
}
await user.click(screen.getByRole('link', { name: 'docs' }))
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYPLAN-064d: the reorder chevrons still move the note instead of editing it', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [
buildDayNote({ id: 55, day_id: 10, text: 'First note', sort_order: 0 }),
buildDayNote({ id: 56, day_id: 10, text: 'Second note', sort_order: 1 }),
] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const row = cardRow(screen.getByText('Second note'))
await user.click(row.querySelectorAll('.reorder-buttons button')[0] as HTMLElement)
expect(mockDayNotesState.moveNote).toHaveBeenCalled()
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYPLAN-065: the note modal deletes only through the confirmation dialog', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
mockDayNotesState.noteUi = { '10': { mode: 'edit', noteId: 55, text: 'My note', time: '', icon: 'FileText', color: null } }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
await user.click(screen.getByRole('button', { name: /^delete$/i }))
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
expect(screen.getByText('Delete note?')).toBeInTheDocument()
const confirm = within(screen.getByText('Delete note?').closest('[class*="z-[10000]"]') as HTMLElement)
await user.click(confirm.getByRole('button', { name: /^delete$/i }))
expect(mockDayNotesState.deleteNote).toHaveBeenCalledWith(10, 55)
// Otherwise the edit modal is left open on a note that no longer exists.
expect(mockDayNotesState.cancelNote).toHaveBeenCalledWith(10)
})
it('FE-PLANNER-DAYPLAN-065b: a note being created offers no delete', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [] }
mockDayNotesState.noteUi = { '10': { mode: 'add', text: '', time: '', icon: 'FileText', color: null } }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
expect(screen.queryByRole('button', { name: /^delete$/i })).toBeNull()
})
// ── Drop on assignment: same-day reorder ─────────────────────────────
@@ -2888,11 +2949,34 @@ describe('DayPlanSidebar', () => {
expect(onReorder).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYPLAN-130: the weather badge falls back to any located trip place', () => {
// #2167 — the badge used to borrow ANY located trip place, so a roadtrip day
// without stops silently showed another city's weather. Day-local only now.
it('FE-PLANNER-DAYPLAN-130: no weather badge for a day without located stops or a bookend hotel (#2167)', () => {
const located = buildPlace({ id: 5, name: 'Somewhere', lat: 48.85, lng: 2.35 })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [located] })} />)
expect(screen.getByTestId('weather-widget')).toBeInTheDocument()
expect(screen.queryByTestId('weather-widget')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-130b: the weather badge anchors to the day bookend hotel, independent of the optimize setting (#2167)', () => {
// The hotel fallback is unconditional (matching the mobile timeline) — turn the
// route-optimization setting OFF to prove the weather anchor ignores it.
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', optimize_from_accommodation: false } } as any)
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })
const accommodations = [{ id: 1, start_day_id: 10, end_day_id: 11, place_name: 'Hotel Roma', place_lat: 41.9, place_lng: 12.5 }]
render(<DayPlanSidebar {...makeDefaultProps({ days: [day, day2], accommodations: accommodations as any })} />)
const widgets = screen.getAllByTestId('weather-widget')
expect(widgets.length).toBeGreaterThan(0)
expect(widgets[0]).toHaveAttribute('data-location', 'Hotel Roma')
})
it('FE-PLANNER-DAYPLAN-130c: the weather badge names the day-local stop it is anchored to (#2167)', () => {
const place = buildPlace({ id: 5, name: 'Louvre', lat: 48.86, lng: 2.34 })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const a = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] } })} />)
expect(screen.getByTestId('weather-widget')).toHaveAttribute('data-location', 'Louvre')
})
it('FE-PLANNER-DAYPLAN-131: a read-only trip drops the edit affordances from day and note rows', () => {
@@ -2907,6 +2991,10 @@ describe('DayPlanSidebar', () => {
expect(screen.queryByLabelText('Add Note')).not.toBeInTheDocument()
expect(dragRow(screen.getByText('Read only place'))).toBeNull()
expect(screen.getByText('A note')).toBeInTheDocument()
// A viewer must not reach the edit modal they could never save (#2249).
const noteRow = cardRow(screen.getByText('A note'))
expect(noteRow).not.toHaveAttribute('role', 'button')
expect(noteRow.querySelectorAll('.reorder-buttons')).toHaveLength(0)
})
it('FE-PLANNER-DAYPLAN-132: the read-only chevron still collapses and expands the day', async () => {
@@ -3496,6 +3584,34 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-163b: every booking on the stop gets its own chip line (#2201)', async () => {
const user = userEvent.setup()
const place = buildPlace({ id: 1, name: 'Zoo' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
const parking = buildReservation({
id: 530, type: 'parking', title: 'Parking pass', status: 'confirmed', assignment_id: 11,
reservation_time: '2025-06-01T09:00:00', reservation_end_time: '2025-06-01T09:30:00',
} as any)
const tickets = buildReservation({
id: 531, type: 'activity', title: 'Zoo tickets', status: 'pending', assignment_id: 11,
reservation_time: '2025-06-01T10:15:00',
} as any)
const onEditReservation = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({
// Newest first, the order the store hands them over after a local create.
days: [day], places: [place], assignments: { '10': [a] }, reservations: [tickets, parking],
onEditReservation,
})} />)
expect(screen.getByText('09:00 – 09:30')).toBeInTheDocument()
expect(screen.getByText('10:15')).toBeInTheDocument()
expect(screen.getByText(/Reservation confirmed/)).toBeInTheDocument()
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
// Earliest first, so the first pencil belongs to the parking pass.
await user.click(screen.getAllByTitle('Edit')[0])
expect(onEditReservation).toHaveBeenCalledWith(parking)
})
it('FE-PLANNER-DAYPLAN-164: travellers on a place row are shown as avatars with an overflow count', () => {
const place = buildPlace({ id: 1, name: 'Group visit' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
+172 -131
View File
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { safeHttpUrl } from '../../utils/safeUrl'
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, StickyNote, TramFront, Zap } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
@@ -30,7 +30,8 @@ import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
getAssignmentReservations,
type MergedItem,
} from '../../utils/dayMerge'
import { withinDriveRange } from '../../utils/geo'
@@ -592,8 +593,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const reachable = (h: { place_lat?: number | null; place_lng?: number | null } | undefined, w: typeof firstWay) =>
!h || !w || w.isPlace || h.place_lat == null || h.place_lng == null
|| withinDriveRange({ lat: h.place_lat, lng: h.place_lng }, w)
const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay)) && reachable(startHotel, firstWay)
const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay)) && reachable(endHotel, lastWay)
// Same carrier evidence the map route uses (#2157): with a located carrier
// endpoint on the day, the no-time default must not open a hotel leg.
const dayHasCarrier = wayPts.some(w => w.carrierEdge != null)
const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay, dayHasCarrier)) && reachable(startHotel, firstWay)
const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay, dayHasCarrier)) && reachable(endHotel, lastWay)
return { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom }
}
@@ -692,19 +696,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
})
}
const openEditNote = (dayId: number, note: DayNote, e?: React.MouseEvent) => {
e?.stopPropagation()
_openEditNote(dayId, note)
}
const openEditNote = (dayId: number, note: DayNote) => _openEditNote(dayId, note)
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
e?.stopPropagation()
await _deleteNote(dayId, noteId)
}
const deleteNote = (dayId: number, noteId: number) => _deleteNote(dayId, noteId)
// Unified reorder: assigns positions to ALL item types based on new visual order
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
@@ -975,7 +973,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
const day = days.find(d => d.id === dayId)
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
? getAccommodationAnchors(day, days, accommodations)
? getAccommodationAnchors(
day, days, accommodations,
unlockedWithCoords.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng! })),
(mergedItemsMap[dayId] || []).some(i => i.type === 'transport' && hasCarrierEndpointOnDay(i.data, dayId)),
)
: {}
const optimizedAssignments = unlockedWithCoords.length >= 2
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id })), anchors).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
@@ -1040,10 +1042,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
return formatMoneySum(entries, costBase, locale, fxRates)
}, [days, assignments, currency, costBase, locale, fxRates])
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
const anyGeoPlace = anyGeoAssignment || (places || []).find(p => p.lat && p.lng)
return {
tripId,
trip,
@@ -1179,8 +1177,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
handleOptimize,
handleDropOnDay,
totalCostLabel,
anyGeoAssignment,
anyGeoPlace,
expandedRouteDayIds,
setExpandedRouteDayIds,
}
@@ -1352,8 +1348,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
handleOptimize,
handleDropOnDay,
totalCostLabel,
anyGeoAssignment,
anyGeoPlace,
expandedRouteDayIds,
setExpandedRouteDayIds,
} = S
@@ -1551,7 +1545,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// routable when accommodation optimization can bookend it with a hotel
// (hotel → place → hotel, the same line the map draws) — otherwise the tools
// vanish on such a day (#1330). Purely additive to the 2+ case.
const routeBookends = optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : null
// The weather anchor below must not depend on the optimize-from-accommodation
// setting — where you wake up is a fact about the day, not a routing choice —
// so the bookend lookup runs unconditionally (mirrors useMPlanTimeline) while
// the route tools keep honoring the setting via routeBookends.
const dayBookends = getDayBookendHotels(day, days, accommodations)
const routeBookends = optimizeFromAccommodation !== false ? dayBookends : null
const hasRouteBookend = !!(
(routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null) ||
(routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null)
@@ -1582,11 +1581,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const dayExportStops = (): NamedWaypoint[] => {
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null, lat: dayStops[0].place!.lat!, lng: dayStops[0].place!.lng! } : undefined
const lastAssignment = dayStops[dayStops.length - 1]
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first)
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last)
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null, lat: lastAssignment.place!.lat!, lng: lastAssignment.place!.lng! } : undefined
// Same carrier gate as the drawn route (#2157): the exported link must not
// start at a hotel you only reach tonight or lead back to one you left.
const dayHasCarrier = (mergedItemsMap[day.id] || []).some(i => i.type === 'transport' && hasCarrierEndpointOnDay(i.data, day.id))
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first, dayHasCarrier)
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last, dayHasCarrier)
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
@@ -1627,6 +1629,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// own, hence the key handler only answers for the row — and it
// toggles exactly like the click, rather than only selecting.
role="button"
// No press-scale on the wide row — it would shift the nested
// buttons out from under the pointer mid-click (#2158).
data-no-press
tabIndex={0}
onClick={() => toggleDaySelection()}
onKeyDown={e => {
@@ -1659,12 +1664,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
>
{/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
{(() => {
// anyGeoPlace is an assignment (has .place) or a bare place — read coords from either.
const geoLat = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lat : anyGeoPlace.lat) : undefined
const geoLng = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lng : anyGeoPlace.lng) : undefined
const wLat = loc?.place?.lat ?? geoLat
const wLng = loc?.place?.lng ?? geoLng
const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
// Day-local anchor only (#2167): the day's first located stop, else the
// hotel you wake up in. No trip-wide fallback — on a roadtrip that
// silently showed another city's weather with nothing naming the place.
const weatherHotel = loc == null ? dayBookends.morning : undefined
const wLat = loc?.place?.lat ?? weatherHotel?.place_lat
const wLng = loc?.place?.lng ?? weatherHotel?.place_lng
const weatherName = loc?.place?.name ?? weatherHotel?.place_name ?? null
const hasWeather = !!(day.date && wLat != null && wLng != null)
return (
<div style={{
// With weather the badge is a tall stack and has to start at
@@ -1691,7 +1698,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<>
<div style={{ width: '64%', height: 1, background: 'currentColor', opacity: 0.25 }} />
<div style={{ padding: '3px 0 4px' }}>
<WeatherWidget lat={wLat} lng={wLng} date={day.date} stacked />
<WeatherWidget lat={wLat ?? null} lng={wLng ?? null} date={day.date} stacked locationName={weatherName} />
</div>
</>
)}
@@ -1953,8 +1960,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Picking the place out of the day has no other trigger, so
// the row is the control. Its grip, lock and arrows are
// buttons in their own right, hence the key handler only
// answers for the row itself.
// answers for the row itself. No press-scale — see the
// day header above (#2158).
role="button"
data-no-press
tabIndex={0}
draggable={canEditDays && !dragDisabled}
onDragStart={e => {
@@ -2121,87 +2130,107 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
</div>
)}
{assignment.notes && (
// Day-specific note on this stop (#2163) — one muted
// caption line so the timeline shows the note exists
// without swallowing the row.
<div title={t('places.assignmentNotes')} style={{ marginTop: 2, display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden' }}>
<StickyNote size={9} strokeWidth={2} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>{assignment.notes}</span>
</div>
)}
{(() => {
const res = reservations.find(r => r.assignment_id === assignment.id)
if (!res) return null
const confirmed = res.status === 'confirmed'
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
// The status, the time and the flight/train number used to sit in one
// pill strung together on a middle dot, which read as one run-on
// sentence. They are separate chips now: same tint so they still
// belong together, own outline so the eye can take them one at a time.
const RI = RES_ICONS[res.type] || Ticket
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
const chip: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '1px 6px', borderRadius: 5,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
whiteSpace: 'nowrap',
}
const { time: st } = splitReservationDateTime(res.reservation_time)
const { time: et } = splitReservationDateTime(res.reservation_end_time)
const timeLabel = st || et
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
: ''
let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
const carrierLabel = meta
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
: ''
const linked = getAssignmentReservations(reservations, assignment.id)
if (linked.length === 0) return null
// A stop can carry more than one booking (a parking pass and the
// tickets for the same zoo), and every one of them is kept out of
// the timeline, so this row is where they have to appear (#2201).
// Stacked, because the chip lines are inline-flex and would
// otherwise run together on one.
return (
// No wrapping: the time belongs to the status it qualifies, so the
// chips stay on one line even when the row gets narrow.
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
<div className={tint} style={chip}>
<RI size={8} />
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
</div>
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
{hasEndpoints && (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={11} />
</button>
)}
{canEditDays && (() => {
const isTransport = TRANSPORT_TYPES.has(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
{linked.map(res => {
const confirmed = res.status === 'confirmed'
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
// The status, the time and the flight/train number used to sit in one
// pill strung together on a middle dot, which read as one run-on
// sentence. They are separate chips now: same tint so they still
// belong together, own outline so the eye can take them one at a time.
const RI = RES_ICONS[res.type] || Ticket
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
const chip: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '1px 6px', borderRadius: 5,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
whiteSpace: 'nowrap',
}
const { time: st } = splitReservationDateTime(res.reservation_time)
const { time: et } = splitReservationDateTime(res.reservation_end_time)
const timeLabel = st || et
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
: ''
let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
const carrierLabel = meta
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
: ''
return (
<button
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
title={t('common.edit')}
className="bg-transparent text-content-faint"
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
</button>
// No wrapping: the time belongs to the status it qualifies, so the
// chips stay on one line even when the row gets narrow.
<div key={res.id} style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
<div className={tint} style={chip}>
<RI size={8} />
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
</div>
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
{hasEndpoints && (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={11} />
</button>
)}
{canEditDays && (() => {
const isTransport = TRANSPORT_TYPES.has(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
return (
<button
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
title={t('common.edit')}
className="bg-transparent text-content-faint"
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
</button>
)
})()}
</div>
)
})()}
})}
</div>
)
})()}
@@ -2336,7 +2365,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div
// Opening the booking has no other trigger, so the row is
// the control; the buttons inside it answer for themselves.
// No press-scale — see the day header above (#2158).
role="button"
data-no-press
tabIndex={0}
onClick={openTransportRow}
onKeyDown={e => {
@@ -2577,17 +2608,33 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
]) : undefined}
{...(canEditDays ? {
role: 'button' as const,
// No press-scale: index.css shrinks every [role=button] on
// :active, which on a draggable row fights the drag (#2158).
'data-no-press': '',
tabIndex: 0,
// The row is the note's edit affordance — the same gesture the
// phone shell already uses (#2249). Links inside the rendered
// body and the reorder buttons keep their own click.
onClick: (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('a, button')) return
openEditNote(day.id, note)
},
onKeyDown: (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) return
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
openEditNote(day.id, note)
},
} : {})}
onMouseEnter={e => {
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '1'
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
if (editBtns) editBtns.style.opacity = '1'
}}
onMouseLeave={e => {
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '0.3'
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
if (editBtns) editBtns.style.opacity = '0'
}}
style={{
position: 'relative',
@@ -2599,7 +2646,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
background: noteSkin.background,
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
transition: 'background 0.1s', cursor: canEditDays ? 'pointer' : 'default', userSelect: 'none',
}}
>
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
@@ -2621,19 +2668,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
)}
</div>
{/* Stacked in a capsule of their own, set off from the text:
a note card is as tall as its body, so the height is free
while the width next to the text is not, and a 10px icon
in a 14px box was barely a target. */}
{/* Floated over the text rather than sharing the row with
it: a note is mostly text, and reserving a column for
two buttons that are invisible until you hover costs
every note a shorter line for the whole time you are
not hovering. */}
{canEditDays && <div className="note-edit-buttons bg-surface-card" style={{ position: 'absolute', right: 5, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, padding: 4, borderRadius: 999, border: '1px solid var(--border-faint)', boxShadow: '0 4px 14px rgba(0,0,0,0.10)', opacity: 0, transition: 'opacity 0.15s' }}>
<button type="button" onClick={e => openEditNote(day.id, note, e)} aria-label={t('dayplan.noteEdit')} title={t('dayplan.noteEdit')} className="text-content-faint note-edit-btn" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Pencil size={15} /></button>
<button type="button" onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} aria-label={t('dayplan.noteDelete')} title={t('dayplan.noteDelete')} className="text-content-faint note-edit-btn note-edit-btn-danger" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Trash2 size={15} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
@@ -2840,6 +2874,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
noteInputRef={noteInputRef}
cancelNote={cancelNote}
saveNote={saveNote}
onRequestDelete={(dayId, noteId) => setPendingDeleteNote({ dayId, noteId })}
t={t}
/>
@@ -2855,7 +2890,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<ConfirmDialog
isOpen={!!pendingDeleteNote}
onClose={() => setPendingDeleteNote(null)}
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
onConfirm={() => {
if (!pendingDeleteNote) return
// Close the edit modal behind the confirm; leaving it open would go on
// editing a note that no longer exists.
cancelNote(pendingDeleteNote.dayId)
deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId)
}}
title={t('dayplan.confirmDeleteNoteTitle')}
message={t('dayplan.confirmDeleteNoteBody')}
/>
@@ -11,6 +11,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
interface NoteModalUi {
mode: 'add' | 'edit'
noteId?: number
icon: string
text: string
time: string
@@ -25,6 +26,8 @@ interface DayPlanSidebarNoteModalProps {
noteInputRef: React.RefObject<HTMLInputElement>
cancelNote: (dayId: number) => void
saveNote: (dayId: number) => void
/** Asks the planner to confirm and then delete the note being edited (#2249). */
onRequestDelete: (dayId: number, noteId: number) => void
t: (key: string, params?: Record<string, unknown>) => string
}
@@ -38,19 +41,19 @@ const BODY_MAX = 2000
* right. The preview is the reason the colour picker is worth having — a swatch
* row tells you nothing about what a tinted card looks like next to a place.
*/
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, t }: DayPlanSidebarNoteModalProps) {
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: DayPlanSidebarNoteModalProps) {
return (
<>
{Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi} noteInputRef={noteInputRef}
cancelNote={cancelNote} saveNote={saveNote} t={t} />,
cancelNote={cancelNote} saveNote={saveNote} onRequestDelete={onRequestDelete} t={t} />,
document.body
))}
</>
)
}
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
dayId: string
ui: NoteModalUi
}) {
@@ -174,7 +177,17 @@ function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote,
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{/* Delete lives here rather than on the row: the row's floating pencil/trash
pill covered the reorder chevrons and, on a coarse pointer, never went
away (#2249). The planner still confirms before it deletes. */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
{ui.mode === 'edit' && ui.noteId != null && (
<button type="button" onClick={() => onRequestDelete(Number(dayId), ui.noteId!)}
className="text-danger"
style={{ marginRight: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.delete')}
</button>
)}
<button type="button" onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={() => saveNote(Number(dayId))} disabled={!canSave}
className={!canSave ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { MapPin, X } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
export interface LocationPoint {
name: string
@@ -19,6 +20,8 @@ interface Props {
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
const { t, locale } = useTranslation()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias()
const [query, setQuery] = useState(value?.name || '')
const [open, setOpen] = useState(false)
const [results, setResults] = useState<any[]>([])
@@ -49,7 +52,7 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
debounceRef.current = setTimeout(async () => {
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale)
const data = await mapsApi.search(trimmed, locale, locationBias)
setResults(data.places || [])
setHighlight(-1)
} catch {
@@ -169,6 +169,28 @@ describe('PlaceDetailsColumn', () => {
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
})
it('FE-PDC-010b: credits a website description by host', async () => {
// The source labels used to be a ternary chain ending in 'Wikipedia', so a
// restaurant's own marketing copy would have been credited to an
// encyclopaedia. There is no fixed name for "the place's own website", and
// the host is the honest one.
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: {
text: 'Pizza in Rostock, seit 2015.',
source: 'website',
sourceUrl: 'https://www.losteria.net/rostock',
license: null,
},
})
renderColumn()
const link = await screen.findByRole('link', { name: /losteria\.net/ })
expect(link).toHaveAttribute('href', 'https://www.losteria.net/rostock')
expect(screen.queryByText(/Wikipedia/)).not.toBeInTheDocument()
})
it('FE-PDC-011: says so when the admin switched enrichment off', async () => {
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [], disabled: true })
renderColumn()
@@ -379,6 +379,37 @@ function PhotoTile({
)
}
/**
* Who the description came from, as the reader would name them.
*
* `website` has no fixed name — it is the place's own site — so it is credited
* by host: "losteria.net" says where the text is from far better than a generic
* word would. Deliberately not a ternary chain: the previous one fell through
* to 'Wikipedia', which would have credited a restaurant's marketing copy to an
* encyclopaedia.
*/
function descriptionSourceLabel(
source: NonNullable<MapsPlaceEnrichmentResult['description']>['source'],
sourceUrl: string | null,
): string {
switch (source) {
case 'google':
return 'Google'
case 'osm':
return 'OpenStreetMap'
case 'wikivoyage':
return 'Wikivoyage'
case 'wikipedia':
return 'Wikipedia'
case 'website':
try {
return new URL(sourceUrl || '').hostname.replace(/^www\./, '')
} catch {
return 'Website'
}
}
}
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google'
if (source === 'wikipedia') return 'Wikipedia'
@@ -659,14 +690,7 @@ function DescriptionBlock({
}): React.ReactElement | null {
if (!description) return null
const sourceLabel =
description.source === 'google'
? 'Google'
: description.source === 'osm'
? 'OpenStreetMap'
: description.source === 'wikivoyage'
? 'Wikivoyage'
: 'Wikipedia'
const sourceLabel = descriptionSourceLabel(description.source, description.sourceUrl)
// A description of the chain is not a description of this place, and the
// heading is where that gets said. Without it the reader has no way to tell
@@ -1,3 +1,5 @@
import type { RoadtripStopType } from '@trek/shared'
export interface PlaceFormData {
name: string
description: string
@@ -19,6 +21,19 @@ export interface PlaceFormData {
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets
// it, and places.service already writes image_url through on create/update.
image_url?: string
// Day-specific note on the in-context assignment (#2163). Only hydrated when
// the form opened with an assignment in context; both forms drop it from the
// submit payload when unchanged, and useTripPlanner strips it off the place
// update and PUTs it per assignment instead.
assignment_notes?: string
// What kind of stop on a drive this is, if any (#1797). Optional and absent from
// DEFAULT_FORM for the same reason as image_url: a missing key writes nothing, and a
// place created outside a road trip has no opinion about fuel stops.
stop_type?: RoadtripStopType | null
// Offered only while creating, and only alongside a stop_type: the corridor popup
// suggests how long that kind of pause usually takes, and the full form should not
// throw the suggestion away on the way through. Editing a stay is the rail's job.
duration_minutes?: number
}
export function isGoogleMapsUrl(input: string): boolean {
+129 -34
View File
@@ -1,13 +1,16 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import Modal from '../shared/Modal'
import type { RoadtripStopType } from '@trek/shared'
import { STOP_KINDS } from '../Roadtrip/stopKinds'
import CustomSelect from '../shared/CustomSelect'
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import { mapsApi } from '../../api/client'
import { recordPlacePick } from '../../api/placeShadow'
import { useAuthStore } from '../../store/authStore'
import { useAddonStore } from '../../store/addonStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
import { useToast } from '../shared/Toast'
@@ -16,6 +19,7 @@ import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError'
import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { Place, Category, Assignment, BudgetItem } from '../../types'
@@ -36,7 +40,7 @@ interface PlaceFormModalProps {
onClose: () => void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
place: Place | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null
tripId: number
categories: Category[]
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
@@ -86,9 +90,20 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
onOpenExpense,
} = props
// Hidden while the addon is off, because the kinds only mean anything to the road trip
// rail: on an instance without it they would be six labels that change nothing.
const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip'))
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
/**
* What produced the list currently on screen, kept for the shadow log: the
* query as typed and the provider the envelope named. A ref rather than
* state because nothing renders from it and a pick must read the value that
* belonged to the list, not a value a re-render replaced.
*/
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewCategory, setShowNewCategory] = useState(false)
@@ -143,6 +158,14 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
// Carried through every edit. Without it, opening a fuel stop to fix a typo
// submits an empty kind and turns it back into a numbered destination.
// duration_minutes deliberately stays out: how long a stay takes belongs to the
// rail's own dialog, and sending it from here would overwrite what was set there.
stop_type: place.stop_type ?? null,
// The day-specific note rides only with an assignment in context (#2163);
// otherwise the key stays absent so submit never sends a notes write.
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
})
} else if (prefillCoords) {
setForm({
@@ -154,6 +177,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
website: prefillCoords.website || '',
phone: prefillCoords.phone || '',
osm_id: prefillCoords.osm_id,
stop_type: prefillCoords.stop_type ?? null,
duration_minutes: prefillCoords.duration_minutes,
})
} else {
setForm(DEFAULT_FORM)
@@ -210,32 +235,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
}, [isOpen])
// Derive location bias bounding box from the trip's existing places
const places = useTripStore((s) => s.places)
const locationBias = useMemo(() => {
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
if (withCoords.length === 0) return undefined
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
for (const p of withCoords) {
const lat = Number(p.lat), lng = Number(p.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
if (lat < minLat) minLat = lat
if (lat > maxLat) maxLat = lat
if (lng < minLng) minLng = lng
if (lng > maxLng) maxLng = lng
}
if (!Number.isFinite(minLat)) return undefined
// Skip bias if the bounding box is too large (~500 km diagonal)
const dlat = maxLat - minLat
const dlng = maxLng - minLng
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
if (diagKm > 500) return undefined
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
}, [places])
// Where the trip is happening — the hint that tells the search which of a
// thousand identically named places is meant. Autocomplete wants a box, the
// search wants a point; useLocationBias derives both from the same places.
const { box: locationBias, point: locationBiasPoint } = useLocationBias()
// Autocomplete fetch — aborts any in-flight request before starting a new one
const fetchSuggestions = useCallback(async (query: string) => {
@@ -249,6 +253,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
acAbortRef.current = controller
try {
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
acMetaRef.current = { query, source: result.source || 'unknown' }
setAcSuggestions(result.suggestions || [])
setAcHighlight(-1)
} catch (err: unknown) {
@@ -307,7 +312,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return
}
}
const result = await mapsApi.search(mapsSearch, language)
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
setMapsResults(result.places || [])
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
@@ -316,7 +322,13 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
}
const handleSelectMapsResult = (result) => {
/**
* `pick` is present only when the click came from a ranked list. The
* collection picker and the autocomplete detour reach this function with a
* place that was never ranked against a query, and a made-up rank would be
* worse than no row at all.
*/
const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
setForm(prev => mergeResult(prev, result, autoFilledRef.current))
// The one point every pick flows through, so the detail column hangs here.
// A new pick drops whatever hero image belonged to the previous place.
@@ -333,12 +345,36 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
details: result,
})
setForm(prev => ({ ...prev, image_url: undefined }))
if (pick) {
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
if (meta) {
recordPlacePick({
query: meta.query,
lang: language,
// The bias the search actually ran under is a box around the trip's
// existing places; the corpus stores its centre, which is what an
// evaluation needs to bias its own index the same way.
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
source: `${pick.mode}:${meta.source}`,
liveRank: pick.rank,
liveCount: pick.count,
pickedName: result.name || '',
pickedLat: lat,
pickedLng: lng,
pickedPlaceId: result.google_place_id || result.osm_id || null,
})
}
}
}
setMapsResults([])
setMapsSearch('')
}
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
// Read before the list is cleared: this is the rank the user saw.
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
const acCount = acSuggestions.length
setAcSuggestions([])
setAcHighlight(-1)
const previousSearch = mapsSearch
@@ -365,11 +401,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
if (!place) {
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(query, language)
const search = await mapsApi.search(query, language, locationBiasPoint)
place = search.places?.[0] ?? null
}
if (place) {
handleSelectMapsResult(place)
handleSelectMapsResult(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
} else {
setMapsSearch(previousSearch)
toast.error(t('places.mapsSearchError'))
@@ -471,13 +507,31 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
setIsSaving(true)
try {
const saved = await onSave({
const payload = {
...form,
lat: form.lat ? Number.parseFloat(form.lat) : null,
lng: form.lng ? Number.parseFloat(form.lng) : null,
category_id: form.category_id || null,
// An explicit null is how a stop stops being a fuel stop; the service reads it
// that way rather than as "leave alone", which is what a missing key means.
stop_type: form.stop_type || null,
// Only on the way in, and only with a kind: it is the popup's suggestion for how
// long that kind of pause takes. On an edit it is left out entirely, because the
// stay belongs to the rail's dialog and sending it here would overwrite it.
...(!place && form.stop_type && form.duration_minutes
? { duration_minutes: form.duration_minutes }
: {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
})
}
// #2163: the per-assignment note only travels when an assignment is in
// context AND the value actually changed — an untouched note must not
// produce a PUT (a legacy note longer than the textarea would otherwise
// be re-sent on every unrelated save).
const ctxAssignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) {
delete payload.assignment_notes
}
const saved = await onSave(payload)
// Open the Costs editor for the saved place when the user asked to
// create/edit its linked expense — gated on an id, so a create that the
// server refused never opens an editor pointing at nothing (#1298).
@@ -515,6 +569,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -587,6 +642,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -756,7 +812,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
<button
key={idx}
type="button"
onClick={() => handleSelectMapsResult(result)}
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
>
<div className="font-medium text-sm">{result.name}</div>
@@ -858,6 +914,30 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
</div>
</div>
{/* What kind of stop this is on a drive.
Under the category rather than beside it, because the two answer different
questions: a category is the traveller's own label, editable and shared across
the whole instance, while this is a fact about the place. Refuelling is not a
taste. Shown always, not only in road trip mode: a fuel stop added on a Tuesday
is still a fuel stop when the mode is switched on later, and this is the only
place in the web UI where the kind can be taken off again. */}
{roadtripEnabled ? (
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('roadtrip.stop.kind')}</label>
<CustomSelect
value={form.stop_type ?? ''}
onChange={value => handleChange('stop_type', String(value) as RoadtripStopType | '')}
placeholder={t('roadtrip.stop.none')}
options={[
{ value: '', label: t('roadtrip.stop.none') },
...STOP_KINDS.map(k => ({ value: k.key, label: t(k.labelKey) })),
]}
size="sm"
/>
<p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.kindHelp')}</p>
</div>
) : null}
{/* Category */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formCategory')}</label>
@@ -924,6 +1004,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
/>
)}
{/* Day-specific note — like the times, it lives on the assignment, not
the pool place (#2163): only editable with one in context. */}
{!!(place && assignmentId) && (
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.assignmentNotes')}</label>
<textarea
value={form.assignment_notes ?? ''}
onChange={e => handleChange('assignment_notes', e.target.value)}
rows={2}
placeholder={t('places.assignmentNotesPlaceholder')}
className="form-input" style={{ resize: 'vertical' }}
/>
</div>
)}
{/* Website */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formWebsite')}</label>
@@ -425,6 +425,33 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Museum Ticket')).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-030g: every booking on the stop gets its own strip (#2201)', () => {
const onEditReservation = vi.fn();
const parking = buildReservation({
id: 530, title: 'Parking pass', status: 'confirmed', type: 'parking', assignment_id: 99,
reservation_time: '2025-06-01T09:00:00',
} as any);
const tickets = buildReservation({
id: 531, title: 'Zoo tickets', status: 'pending', type: 'activity', assignment_id: 99,
reservation_time: '2025-06-01T10:15:00',
} as any);
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
render(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
reservations={[tickets, parking]}
onEditReservation={onEditReservation}
/>
);
expect(screen.getByText('Parking pass')).toBeTruthy();
expect(screen.getByText('Zoo tickets')).toBeTruthy();
fireEvent.click(screen.getByText('Zoo tickets').closest('[role="button"]') as HTMLElement);
expect(onEditReservation).toHaveBeenCalledWith(tickets);
});
it('FE-PLANNER-INSPECTOR-030b: the linked reservation opens its editor (#2012)', () => {
const onEditReservation = vi.fn();
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
@@ -1402,4 +1429,53 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Eiffel Tower')).toBeTruthy();
});
// ── Day-specific assignment note (#2163) ─────────────────────────────────────
it('FE-PLANNER-INSPECTOR-099: the assignment note renders as markdown under its own eyebrow', () => {
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: 'Book the **10:00** timed entry' }];
render(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
/>
);
expect(screen.getByText('Notes for this day')).toBeTruthy();
const strong = Array.from(document.querySelectorAll('strong')).find(el => el.textContent === '10:00');
expect(strong).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-100: without a note (or without a day in context) the block stays away', () => {
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
const { rerender } = render(
<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={99} assignments={{ '1': assignmentInDay }} />
);
expect(screen.queryByText('Notes for this day')).toBeNull();
// A note exists but no day is selected — nothing to attribute it to, so no block.
rerender(
<PlaceInspector {...defaultProps} selectedDayId={null} assignments={{ '1': [{ ...assignmentInDay[0], notes: 'hidden' }] }} />
);
expect(screen.queryByText('Notes for this day')).toBeNull();
});
it('FE-PLANNER-INSPECTOR-101: the reservation strip opts out of the press-scale (#2158)', () => {
// jsdom cannot replay the browser mechanics: the :active scale on the
// composite strip slid the links inside out from under the pointer, so the
// click landed elsewhere. The data-no-press attribute is the pin.
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
render(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
reservations={[reservation]}
onEditReservation={vi.fn()}
/>
);
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
});
});
+107 -82
View File
@@ -6,12 +6,13 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route } from 'lucide-react'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route, StickyNote } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker'
import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors'
import { filesForPlace } from '../../utils/placeFiles'
import GuestBadge from '../shared/GuestBadge'
import StatusBadge from '../Collections/StatusBadge'
import { mapsApi, pluginsApi } from '../../api/client'
@@ -30,7 +31,7 @@ import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/f
import { useTripStore } from '../../store/tripStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
import { NavigationMenu } from '../shared/NavigationMenu'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine } from './placeHoursFormat'
@@ -337,7 +338,8 @@ export default function PlaceInspector({
const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date, placeTimeZone)
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id))
// Its own files plus the ones on the bookings that hang on it (#2217).
const placeFiles = filesForPlace(files, place.id, reservations, selectedAssignmentId != null ? [selectedAssignmentId] : [])
return (
<div
@@ -428,6 +430,20 @@ export default function PlaceInspector({
</div>
)}
{/* Day-specific assignment note (#2163) — written via MCP/API or the
edit form; distinct from the pool-wide place.notes above, so it
carries an eyebrow saying which day-scope it belongs to. */}
{assignmentInDay?.notes && (
<div className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, padding: '8px 12px' }}>
<div className="text-content-faint" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 3 }}>
<StickyNote size={10} /> {t('places.assignmentNotes')}
</div>
<div className="collab-note-md text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{assignmentInDay.notes}</Markdown>
</div>
</div>
)}
{/* Reservation + Participants — trip-only (collections have no days) */}
{mode === 'trip' && (
<PlaceReservationParticipants selectedAssignmentId={selectedAssignmentId} reservations={reservations}
@@ -825,92 +841,101 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
return (
<>
{(() => {
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
const linked = getAssignmentReservations<Reservation>(reservations, selectedAssignmentId)
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
const currentParticipants = assignment?.participants || []
const participantIds = currentParticipants.map(p => p.user_id)
const allJoined = currentParticipants.length === 0
const showParticipants = selectedAssignmentId && tripMembers.length > 1
if (!res && !showParticipants) return null
if (linked.length === 0 && !showParticipants) return null
return (
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
{/* Reservation */}
{res && (() => {
const confirmed = res.status === 'confirmed'
// The strip summarised the booking but went nowhere, so its
// attachments and fields had no route from the map (#2012).
// A transport has its own form; picking by type is what the day
// sidebar does, and an absent handler means this user may not
// open this one — so the strip stays inert rather than lying.
const editor = TRANSPORT_TYPES.has(res.type) ? onEditTransport : onEditReservation
const open = editor ? () => editor(res) : undefined
return (
<div
role={open ? 'button' : undefined}
aria-label={open ? t('inspector.editRes') : undefined}
tabIndex={open ? 0 : undefined}
onClick={open}
onKeyDown={open ? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() }
} : undefined}
title={open ? t('inspector.editRes') : undefined}
style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}`, cursor: open ? 'pointer' : undefined, textAlign: 'left' }}
>
<div className={confirmed ? 'bg-[rgba(22,163,74,0.08)]' : 'bg-[rgba(217,119,6,0.08)]'} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
<div className={confirmed ? 'bg-[#16a34a]' : 'bg-[#d97706]'} style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }} />
<span className={confirmed ? 'text-[#16a34a]' : 'text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
<span style={{ flex: 1 }} />
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
</div>
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{(() => {
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
return (
<>
{date && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{(startTime || endTime) && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.time')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
</div>
</div>
)}
</>
)
})()}
{res.confirmation_number && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div>
<div className={`grid ${linked.length > 0 && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
{/* Bookings pinned to this stop; several can share one (#2201) */}
{linked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{linked.map(res => {
const confirmed = res.status === 'confirmed'
// The strip summarised the booking but went nowhere, so its
// attachments and fields had no route from the map (#2012).
// A transport has its own form; picking by type is what the day
// sidebar does, and an absent handler means this user may not
// open this one, so the strip stays inert rather than lying.
const editor = TRANSPORT_TYPES.has(res.type) ? onEditTransport : onEditReservation
const open = editor ? () => editor(res) : undefined
return (
<div
key={res.id}
role={open ? 'button' : undefined}
// No press-scale on the composite strip: shrinking it
// mid-click slides the links inside out from under the
// pointer (#2158).
data-no-press
aria-label={open ? t('inspector.editRes') : undefined}
tabIndex={open ? 0 : undefined}
onClick={open}
onKeyDown={open ? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() }
} : undefined}
title={open ? t('inspector.editRes') : undefined}
style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}`, cursor: open ? 'pointer' : undefined, textAlign: 'left' }}
>
<div className={confirmed ? 'bg-[rgba(22,163,74,0.08)]' : 'bg-[rgba(217,119,6,0.08)]'} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
<div className={confirmed ? 'bg-[#16a34a]' : 'bg-[#d97706]'} style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }} />
<span className={confirmed ? 'text-[#16a34a]' : 'text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
<span style={{ flex: 1 }} />
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
</div>
)}
</div>
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const parts: string[] = []
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
else if (meta.flight_number) parts.push(meta.flight_number)
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`)
if (meta.train_number) parts.push(meta.train_number)
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
if (parts.length === 0) return null
return <div className="text-content-muted" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500 }}>{parts.join(' · ')}</div>
})()}
</div>
)
})()}
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{(() => {
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
return (
<>
{date && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{(startTime || endTime) && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.time')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
</div>
</div>
)}
</>
)
})()}
{res.confirmation_number && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div>
</div>
)}
</div>
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const parts: string[] = []
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
else if (meta.flight_number) parts.push(meta.flight_number)
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`)
if (meta.train_number) parts.push(meta.train_number)
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
if (parts.length === 0) return null
return <div className="text-content-muted" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500 }}>{parts.join(' · ')}</div>
})()}
</div>
)
})}
</div>
)}
{/* Participants */}
{showParticipants && (
@@ -43,6 +43,9 @@ export function ListImportModal(S: SidebarState) {
)}
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginBottom: 16 }}>
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
{/* Same box, same Share button: which screen the link came from is the URL's
business, and until it said so nobody knew a route could be pasted here. */}
{listImportProvider === 'google' ? <div style={{ marginTop: 4 }}>{t('places.googleDirHint')}</div> : null}
</div>
<input
type="text"
@@ -6,6 +6,7 @@ import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { buildAssignmentOptions } from './assignmentOptions'
import AddressInput from './AddressInput'
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2, ParkingSquare } from 'lucide-react'
import { useToast } from '../shared/Toast'
@@ -32,31 +33,6 @@ const TYPE_OPTIONS = [
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
]
function buildAssignmentOptions(days, assignments, t, locale) {
const options = []
for (const day of (days || [])) {
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
if (da.length === 0) continue
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
const groupLabel = `${dayLabel}${dateStr}`
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
for (let i = 0; i < da.length; i++) {
const place = da[i].place
if (!place) continue
const timeStr = place.place_time ? ` · ${place.place_time}${place.end_time ? ' – ' + place.end_time : ''}` : ''
options.push({
value: da[i].id,
label: ` ${i + 1}. ${place.name}${timeStr}`,
searchLabel: place.name,
groupLabel,
dayDate: day.date || null,
})
}
}
return options
}
interface ReservationModalProps {
isOpen: boolean
onClose: () => void
@@ -278,8 +254,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
// Hotels link a place through the accommodation record; every other type links
// the picked trip place/activity directly on the reservation (#1353).
place_id: form.type === 'hotel' ? null : (form.place_id || null),
metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints: [],
// An empty object, not null: null clears the column outright, and that
// took the mirrored booking price with it on every edit of a type that
// fills no metadata of its own — restaurant, event, tour, parking, other,
// a hotel without check-in times (#2233). An object still clears what the
// form dropped, and lets the server carry the price across.
metadata,
// Omitted on an edit: the server replaces the endpoint set whenever the
// key is present, and this form never edits endpoints, so sending an
// empty list would drop a transit booking's stations (#2216).
...(reservation?.id ? {} : { endpoints: [] }),
needs_review: false,
}
if (form.type === 'hotel' && (form.hotel_start_day || form.hotel_end_day)) {
@@ -614,6 +614,9 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
className="bg-surface-card"
onClick={() => onOpen(r)}
role="button"
// No press-scale on the card — it would shift the nested controls out
// from under the pointer mid-click (#2158).
data-no-press
tabIndex={0}
onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen(r) } }}
style={{ borderRadius: 12, border: '1px solid rgba(124,58,237,0.22)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 9, cursor: 'pointer', transition: 'box-shadow 0.15s ease' }}
@@ -1,4 +1,4 @@
// FE-PLANNER-TRANSITJOURNEY-001 to 016 — the journey view for a saved transit entry.
// FE-PLANNER-TRANSITJOURNEY-001 to 018 — the journey view for a saved transit entry.
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -113,6 +113,21 @@ describe('TransitJourneyModal', () => {
expect(onChangeRoute).toHaveBeenCalled()
})
it('FE-PLANNER-TRANSITJOURNEY-017: edit details hands off to the full transport editor', async () => {
const user = userEvent.setup()
const onEditDetails = vi.fn()
render(<TransitJourneyModal {...makeProps({ onEditDetails })} />)
await user.click(screen.getByRole('button', { name: /Edit details/ }))
expect(onEditDetails).toHaveBeenCalled()
})
it('FE-PLANNER-TRANSITJOURNEY-018: edit details stays hidden without the callback or edit rights', () => {
const { rerender } = render(<TransitJourneyModal {...makeProps()} />)
expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument()
rerender(<TransitJourneyModal {...makeProps({ onEditDetails: vi.fn(), canEdit: false })} />)
expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument()
})
it('FE-PLANNER-TRANSITJOURNEY-004: delete asks for confirmation, then calls onDelete', async () => {
const user = userEvent.setup()
const onDelete = vi.fn().mockResolvedValue({})
@@ -15,7 +15,9 @@ import type { Reservation } from '../../types'
* The journey view for an automated public-transit entry (#1065): a roomy modal
* around the stop-by-stop itinerary. The title renames inline right in the
* header, notes get the full width with markdown support, and "Change route"
* re-enters the transit search pre-seeded with this journey's route.
* re-enters the transit search pre-seeded with this journey's route. "Edit
* details" hands off to the full transport editor for the booking fields
* (travelers, costs, files, code, status — #2148).
*/
interface TransitLegMeta {
@@ -38,10 +40,12 @@ interface TransitJourneyModalProps {
onSave: (fields: { title: string; notes: string | null }) => Promise<unknown>
onDelete: () => Promise<unknown>
onChangeRoute: () => void
/** Switch to the full transport editor (travelers, costs, files, code, status). */
onEditDetails?: () => void
canEdit: boolean
}
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, canEdit }: TransitJourneyModalProps) {
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, onEditDetails, canEdit }: TransitJourneyModalProps) {
const { t, locale } = useTranslation()
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
@@ -160,6 +164,15 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
<RefreshCw size={13} /> {t('transit.changeRoute')}
</button>
)}
{canEdit && onEditDetails && (
<button type="button" onClick={onEditDetails} className="text-content-muted" style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 16px', borderRadius: 10,
border: '1px solid var(--border-primary)', background: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Pencil size={13} /> {t('transit.editDetails')}
</button>
)}
{canEdit ? (
<button type="button" onClick={save} disabled={saving || !title.trim() || !dirty} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{
padding: '8px 20px', borderRadius: 10, border: 'none',
@@ -175,6 +175,29 @@ describe('TransitSearchPanel', () => {
await user.click(await screen.findByText(/London Victoria/))
}
/**
* An empty result is ambiguous without the backend's name: the fallback to
* Transitous is silent, so "no connections" could mean the provider has no
* data here or that the provider you picked never ran (#1699).
*/
it('FE-PLANNER-TRANSIT-006b: the empty state names the backend that answered', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [], provider: 'transitous' })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndLondon(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/No connections found via Transitous/)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-006c: falls back to the unnamed message when the server sends no provider', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndLondon(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/^No connections found\./)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import type { Day, Place, Accommodation } from '../../types'
import type { TransitProvider } from '@trek/shared'
/**
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
@@ -36,6 +37,14 @@ interface TransitPlaceResult { name: string; lat: number; lng: number; type: str
export interface PickedPlace { name: string; lat: number; lng: number }
// Backend names as they are written, not translated — the empty state says
// which one answered so "nothing here" can be told apart from "not the one you
// picked" (#1699).
const PROVIDER_NAMES: Record<TransitProvider, string> = {
transitous: 'Transitous',
google: 'Google',
}
// ── helpers ──────────────────────────────────────────────────────────────────
const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
@@ -340,7 +349,7 @@ interface TransitSearchPanelProps {
}
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
const { t } = useTranslation()
const { t, language } = useTranslation()
const toast = useToast()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
@@ -355,6 +364,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const [loading, setLoading] = useState(false)
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
const [addingIdx, setAddingIdx] = useState<number | null>(null)
const [provider, setProvider] = useState<TransitProvider | null>(null)
// Quick picks: the day's located places, plus the trip's located accommodations.
const quickPicks = useMemo<PickedPlace[]>(() => {
@@ -387,6 +397,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
setLoading(true)
setItineraries(null)
setExpandedIdx(null)
setProvider(null)
try {
const tzFrom = tzAt(from.lat, from.lng)
const tzTo = tzAt(to.lat, to.lng)
@@ -395,7 +406,11 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : tzFrom)
const allModes = activeModes.size === MODE_GROUPS.length
const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes, lang: language })
// Which backend actually answered (#1699). Named in the empty state so
// "nothing found" can be told apart from "the provider you picked never
// ran" — the fallback to Transitous is silent by design.
setProvider(d.provider ?? null)
// MOTIS names the request coordinates START/END — swap in the places the
// user actually picked so walks read "Walk to Zoologischer Garten".
const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n)
@@ -606,7 +621,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
)}
{!loading && ranked && ranked.length === 0 && (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{t('transit.noResults')}
{provider
? t('transit.noResultsVia', { provider: PROVIDER_NAMES[provider] })
: t('transit.noResults')}
</div>
)}
{!loading && ranked && ranked.length > 0 && (
@@ -1192,6 +1192,93 @@ describe('TransportModal', () => {
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
});
it('FE-PLANNER-TRANSMODAL-058: a car booking keeps its stops in order between pick-up and return (#1797)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen Berlin → Prag');
// Pick-up and return are the rental frame; the stops sit between them.
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
// Pick-up and return come first in the form, the stops follow — so the saved
// route is from · stop · stop · to even though the fields read in another order.
const fields = screen.getAllByTestId('location-select');
expect(fields).toHaveLength(4);
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
fireEvent.change(fields[1], { target: { value: 'Prag' } });
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'stop', 'to']);
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Dresden', 'Bastei', 'Prag']);
});
it('FE-PLANNER-TRANSMODAL-060: the stops of a car booking can be put in another order', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const fields = screen.getAllByTestId('location-select');
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
fireEvent.change(fields[1], { target: { value: 'Prag' } });
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
// The order of the stops is the order they are driven, and `sequence` is derived
// from it on save — so without this the only way to swap two was to retype both.
await userEvent.click(screen.getAllByRole('button', { name: /Move down/i })[0]);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Bastei', 'Dresden', 'Prag']);
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
});
it('FE-PLANNER-TRANSMODAL-061: the ends of the stop list cannot be pushed past themselves', async () => {
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
// A single stop has nothing to swap with, so the buttons stay away entirely.
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
expect(screen.queryByRole('button', { name: /Move up/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const up = screen.getAllByRole('button', { name: /Move up/i });
const down = screen.getAllByRole('button', { name: /Move down/i });
expect(up[0]).toBeDisabled();
expect(down[down.length - 1]).toBeDisabled();
});
it('FE-PLANNER-TRANSMODAL-059: a stop removed from a car booking is gone from the saved route', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
await userEvent.click(screen.getByRole('button', { name: /Remove stop/i }));
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
const fields = screen.getAllByTestId('location-select');
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
fireEvent.change(fields[1], { target: { value: 'Prag' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
});
it('FE-PLANNER-TRANSMODAL-057: the open-file link on an attached document does not throw', async () => {
const res = buildReservation({ id: 23, type: 'flight', title: 'LH 400' });
const attached = buildTripFile({ id: 72, original_name: 'boarding.pdf' });

Some files were not shown because too many files have changed in this diff Show More