Compare commits

..

426 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
github-actions[bot] 33a33e7b1d chore: bump version to 4.1.1 [skip ci] 2026-08-29 14:42:16 +00:00
Maurice 376332d989 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:54 +02:00
Maurice 43fbee94f9 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:40:54 +02:00
Maurice 0b2cf1c5ef 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:40:54 +02:00
Maurice 241f152347 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:40:54 +02:00
Maurice 8c85323d6d 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:40:54 +02:00
Maurice 8635bec507 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:40:54 +02:00
Maurice 57637cb447 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:40:54 +02:00
michael-bohr df9e2d542a 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:40:54 +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
github-actions[bot] c87e6d2fc2 chore: bump version to 4.1.0 [skip ci] 2026-08-29 11:16:19 +00:00
Maurice e6ccaf89de Revert "chore: bump version to 4.1.0 [skip ci]"
This reverts commit 88ebf89ab3.
2026-08-29 13:15:22 +02:00
Maurice 061a3d91a0 fix(notices): keep the release modal up across the whole 4.x line
The 4.0.0 notice was scoped to a 4.0.x window on the assumption that each
release would bring its own entry. 4.1.0 shipped without one, so the modal
switched itself off and nothing took its place: the thank-you notice it
replaced is retired below 4.0.0, leaving a 4.1 install with no notice at all.

Hold it open to the 5.0.0 boundary instead. What it introduces is the 4.x
line, which is still what somebody arriving on 4.1 or 4.2 is meeting for the
first time, and a later major gets its own entry the way this one did.
2026-08-29 13:14:30 +02:00
github-actions[bot] 88ebf89ab3 chore: bump version to 4.1.0 [skip ci] 2026-08-29 10:59:15 +00:00
Maurice 9162f5d7ac fix(maps): stop the day route drawing drives to airports you flew from
A flight leaving overnight contributes only its departure airport on the day
it leaves, and the evening hotel bookend was attaching to whatever point sat
at the day's edge. So the map drew a straight line from the airport you took
off from to the hotel you were checking into.

The same blind spot on the run builder produced the long rays: a day's order
is a bare wall clock, so an inbound long-haul whose departure clock reads late
sorts after the day's local stops, and its far departure airport got appended
to that run. OSRM answers such a pair with NoRoute and the fallback straightens
the whole chunk, taking the genuine local drive with it.

Both now ask whether the pair could be driven at all. A flight, train, ferry,
cruise or coach carries you out of the day's geography, so its departure point
has no drive back to tonight's hotel and its arrival point had no drive from
this morning's. A hire car is the opposite and stays out of that set: you keep
driving it, so a multi-day rental's pickup and drop-off points keep their hotel
legs. Alongside it, any leg touching a booking endpoint has to be within
MAX_DRIVE_KM, which catches the mis-sorted edges the role alone cannot see.

The sidebar gets both guards so its hotel connectors stay in step with the
drawn route; the mobile map and timeline read the same hook.
2026-08-29 12:23:30 +02:00
Maurice 3c865c9605 docs(wiki): document plugins:use
The scope that turns on third-party tool execution was the one missing from the
scope reference, and the count beside it was stale. It says what the scope does
not do, since the boundary is the admin's mcp:tools grant rather than anything on
the token.
2026-08-29 12:22:59 +02:00
Maurice a8a05ace40 fix(plugins): close the gaps between what a tool schema advertises and what it enforces
`.meta()` advertises every node of the declaration; `nodeToZod` only walks the
ones it can type. Everything below follows from that gap, so the keyword
allowlist moves into `sanitiseNode`, which is the one walker that sees every
node.

A `pattern` was compiled straight into a RegExp and run on the main thread
against caller input, with no timeout and nothing to abort it: `^(a+)+$` against
33 characters stops the whole server, not just MCP. That is an escape from the
supervised child into the host event loop, and it does not take malice, since a
hand-written email pattern is the classic accident. Quantified groups that repeat
or alternate inside are refused, and the source is capped.

`mcp:tools` counted as a side effect, so `readOnlyHint` was structurally
unreachable: holding that grant is what lets a plugin advertise a tool at all.
Every plugin tool was therefore advertised as potentially destructive and every
client's auto-approval was off. It is a host entry point like `hook:*`, and is
classified as one.

Property names, `default` and `examples` were the strings that never met the
sanitiser. A key may carry newlines, so `\n## SYSTEM\n...` in one defeated the
newline collapse everything else goes through. Names are identifiers now; sample
values keep their own keys but have their strings bounded.

Also refused at parse time, which is what the rest of the file already does: an
object `enum` member carrying `$ref` past the deep scan, a non-boolean
`additionalProperties` advertised and checked by nothing, `enum` beside
`properties` skipping the children, a root `enum`/`const`/`nullable` producing a
non-object schema the SDK cannot read, and a draft-4 `exclusiveMinimum: true`
dropped from the check while staying in the advertisement.

`required` used `in`, so a field named `toString` read as present on every call.
Nodes are built with a null prototype, and the three names Zod cannot round-trip
are refused rather than silently lost.

A result block that had to be shortened got no marker and no log line, which
reads to the model like the whole answer. It is counted now, like a dropped one,
and `isError` survives every envelope path.

The security test file carried two raw NUL bytes, so git, GitHub and ripgrep all
treated it as binary: no diff, no blame, no review. Escaped, same runtime value.
2026-08-29 12:22:59 +02:00
Renzo Beux 937e32924d fix(plugins): close six gaps in the MCP tool boundary
Found in review of the branch. Each one is a case where the host told the
model or the admin something it did not enforce.

Advertised schemas are now enforced. buildToolInputSchema built a loose
object that checked only which keys were present, so a tool advertising
an integer, an enum and additionalProperties: false accepted a string, an
unlisted value and an extra property. .meta() still advertises the
declaration verbatim, and the schema underneath it is now built from that
same declaration, so the two cannot disagree. Anything the builder cannot
enforce (oneOf, unknown formats, non-primitive enum members) is refused by
parseManifest rather than advertised, and the failure lands at install
time where an admin sees it.

Writing the test for that turned up a live bug: z.custom() cannot be
represented in JSON Schema, so building enums that way would have thrown
inside tools/list for the whole session. Enums and consts are real literal
types now.

readOnlyHint classification fails closed. The side-effect list spelled out
db:write: and missed db:create:trips entirely, letting a plugin that can
create trips advertise a tool as read-only and idempotent, which is what
clients gate auto-approval on. It is an allowlist of read-only grants now,
and mcp-tool-annotations.test.ts walks the whole catalog so a new
permission fails a test rather than producing a wrong hint.

The result cap covers the whole result. It bounded each text block, so a
hundred blocks cost a hundred times the limit: 6.5M characters in a probe,
now 65K. Large objects are serialised with a budget instead of stringified
whole and then sliced, and the block count is capped.

The admin tool list was dead code. The registry preview carries only
widget and tripPage, so the section added for it never rendered, and the
UI hid the description behind the title when both existed. The preview
carries sanitised tools now (previewManifest is exported and tested, which
is how the gap survived), and both fields render, description always.

plugins:use is out of the DCR default. Adding it to ALL_SCOPES meant a
registration that names no scopes got it and had it pre-selected at
consent. Defaults come from DEFAULT_CLIENT_SCOPES now; asking for it by
name still works.

The public ManifestCapabilities gained mcpTools, so a typed manifest can
express what the runtime already validates.

MCPANN-001..005, MCPENF-001..010, MCPPREV-001..005, MCPTOOLS-020..022,
SDKP-015b/c.
2026-08-29 12:22:59 +02:00
Renzo Beux eb7d595203 feat(admin): show the MCP tools a plugin will advertise
The manifest declaration only becomes an admin-visible control if the
admin can actually read it. Nothing rendered capabilities.mcpTools, so
approving mcp:tools meant approving text you would first meet inside a
chat.

The pre-install dialog now lists the tool names and descriptions, beside
the existing access and egress sections, and the grant gets an
at-a-glance chip like every other capability.

Desktop and mobile both, since the panels are parallel copies.
2026-08-29 12:22:59 +02:00
Renzo Beux 957cdffa7b test(mcp): end-to-end cover for plugin-contributed tools
Integration rather than unit, because what is being proved is that
nothing reaches the global exception filters. registerTools is called
outside the transport's try block, and mcp-transport.service.ts states
that every response there is written by hand and nothing may be thrown,
so a registry-level unit test cannot show it.

MCP-P15: a contributed tool that takes a built-in's name is skipped, the
built-in is the one still listed, and the session serves normally.
MCP-P16: a source that throws costs its tools and nothing else, where it
would previously have been a 500 on every /mcp initialize.
MCP-P17: a contributed tool call writes the same mcp.tool_call audit row
as a built-in, which is the whole reason the source hangs off
McpAttachOptions rather than the host calling registerTool itself.

MCP-P18 came out of writing the others: the first draft of MCP-P17 minted
a trips:read token and got no audit row, because the declarative access
marker had correctly hidden the tool. That is worth pinning.

mintOauthToken takes scopes now, and rpcResult parses the SSE frame the
transport answers with.
2026-08-29 12:22:59 +02:00
Renzo Beux 65a6a46d70 fix(plugins): invalidate MCP sessions on lifecycle and grant changes
The advertised tool surface is frozen at initialize, so anything that
changes which plugin tools exist has to end the sessions built before it.
tools/list_changed is not an alternative: no event store is configured,
and the surface would not be re-read anyway.

Unconditional, no diffing. The surface is per-session, since it depends
on the caller's scopes and the plugin's grants, so there is no ctx-free
set to compare against.

Five routes, mirroring what admin.controller.ts already does for addons:
activate, deactivate, update, retrust and uninstall. activate(id,
consent), update and retrust are also the grant-change paths, so "on an
mcp:tools grant change" needs nothing extra. A failed call invalidates
nothing, because nothing changed.

The addon cascade needed its own call. Disabling an addon can stop a
plugin that publishes tools, and the addon doing the cascading is not
necessarily one of MCP_RELEVANT_ADDONS, so mcpAffected does not cover it.
Still gated on a plugin actually stopping, which keeps the #1414 rule
that a config-only save kills nothing.

Imported from sessionManager rather than the src/mcp barrel, which
evaluates readEnv().mcp at module scope.

MCPINV-001 to MCPINV-007.
2026-08-29 12:22:59 +02:00
Renzo Beux 21fd04647f feat(plugins): advertise plugin tools on the MCP server
The advertise path, shaped like trip-warnings.mcp.ts: gate on the kill
switch, fan out over providersOf, let a failing provider contribute
nothing rather than fail the surface.

A tool is advertised only if the signed manifest declares it AND the
running build reported implementing it, the same two-sided check
callPlugin makes for exports. The manifest half is what makes the text
auditable: the child composes its loaded payload after onLoad, so without
it a sha256-pinned artifact could change what it publishes on every
restart with no re-consent.

The result envelope is built here, always. The registry returns a
handler's value verbatim and skips output validation without an
outputSchema, so a plugin returning {ok: true, data} would emit a result
with no content key and every conforming client would error. A failed or
timed-out invoke becomes isError rather than a JSON-RPC fault, so the
model can see it and try something else.

Gates in order: kill switch, then demo mode. The plugins domain had no
demo call site before, and the child has none of its own.

The service owns the process-level source rather than PluginRuntimeService
beside the other sinks, because it needs PluginHooks and PluginHooks
injects PluginRuntimeService. It clears the source on destroy, which the
existing setPluginChannelSource forgets to do: a torn-down buildApp()
would otherwise leave a live source closing over a dead runtime and bleed
into the next suite.

mcpToolCapabilities is a parallel reader rather than a capabilityList
generalisation, since that one returns string[]. It re-runs the manifest
parser instead of trusting the stored blob, which was written by whatever
parser was current at install time and whose caps are the security
control.

Two caps not in the brief: MCP_TOOLS_TOTAL_MAX across all plugins, since
8 each times twenty installed plugins would put 160 tools in front of a
model already choosing from ~200. Truncation is logged, because a
truncated surface reads exactly like a plugin that published nothing.

MCPTOOLS-001 to MCPTOOLS-019.
2026-08-29 12:22:59 +02:00
Renzo Beux c6027f9e68 feat(plugins): report implemented MCP tools at load
The child adds the tool names its mcpToolProvider hook implements to the
loaded payload, beside routes/jobs/exports, and the supervisor caches
them on Supervised like the rest. The hook itself already rides the
existing Object.keys(def.hooks) line, so the "did the child report the
hook too" half was free.

Declarative and synchronous: the host never asks the child what tools it
has. It intersects this list with the signed manifest, so a build that
reports a tool the manifest never declared is ignored rather than
trusted.

grantsOf comes along because the advertise path has to clamp what a
tool's annotations may claim against what the plugin was actually
granted, and sup.granted is already in memory. A per-tool DB read would
be the alternative.

plugin-host-entry.ts is excluded from coverage (it runs in a forked
subprocess), so the two supervisor integration cases are the only thing
proving any of this: the loaded payload, the acting-user-bound invoke,
and that both stop the moment the plugin is no longer active.
2026-08-29 12:22:59 +02:00
Renzo Beux 29de8dd9c1 feat(plugins): capabilities.mcpTools in the manifest
Tools are declared in the signed manifest, not only reported at load.
plugin-host-entry composes its loaded payload AFTER onLoad runs, so a
signed, sha256-pinned artifact could otherwise emit different tool text on
every restart with no version bump and no re-consent. The host will
advertise the intersection of the two.

It also gives the admin something to read. What mcp:tools will put in
front of every user is a manifest field now, so it can be shown on the
consent screen before the grant is given.

Rejects rather than drops, unlike most capability parsing: a malformed
tool should fail the install visibly, not become a tool that silently
never appears once the plugin is running. Caps and sanitising come from
mcp-tool-schema.ts, so the install path and the advertise path cannot
disagree about them.

Declaring tools without mcp:tools is an error on both sides. Otherwise
the consent screen lists tools that can never run.

No migration: install/discovery.ts already persists capabilities as JSON.

The SDK validator mirrors the checks it can make without the server's
normaliser, so `trek-plugin validate` catches a bad declaration before
publish rather than at install.
2026-08-29 12:22:59 +02:00
Renzo Beux 41de91668b feat(plugins): normalise and sanitise plugin-authored assistant text
The security control for plugin MCP tools, landed on its own so it is
reviewable as one diff. Pure and Nest-free: no container, no DB, no
running plugin needed to test it.

The threat is not a plugin crashing. A tool name, title and description
are a persistent, admin-invisible write into every user's assistant
context, and .meta() puts the JSON Schema there verbatim. TREK's own code
proves the channel is obeyed: the static-token deprecation notice is
delivered in a tool result precisely because that is what an assistant
reliably surfaces.

sanitiseAssistantText is a sibling of stripEmoji, not a change to it.
stripEmoji preserves newlines on purpose (PDF paragraphs) and ~15 render
call sites depend on that. The new one collapses every whitespace run to
a single space, so a description cannot forge "## System" as a heading
that reads to the model like host text, and strips more than the brief
asked for: the bidi overrides and isolates and U+2028/9 are neither C0
controls nor \n, so they defeat both halves of the stated rule.

The schema is advertised rather than converted. z.looseObject({}).meta()
passes the plugin's own schema through byte for byte, keeping pattern,
oneOf and format, which a hand-rolled converter flattens to unknown. The
.check() keeps required enforced, so the handler cannot receive a call
missing arguments its own schema declared mandatory.

That only works with a normaliser in front, since .meta() is a
passthrough. It rejects a root type that is not object (which would
advertise a non-object inputSchema while the runtime still passes an
object), rejects $ref/$defs/definitions at any depth, and caps bytes,
properties and nesting. It strips a top-level id, which vanishes from the
advertisement and lands in z.globalRegistry._idmap, a strong Map unlike
the WeakMap holding the rest.

Sanitising recurses into every nested description, title and string enum
member. That is the part a top-level cap misses, and where a property
named trek_session_token with a plausible description would live.

Annotations are clamped asymmetrically. readOnlyHint gates client
auto-approval, so a plugin may lower its danger level and never raise it;
openWorldHint goes the other way, since egress makes a tool open-world
whether it admits it or not. db:own is deliberately not a side effect:
counting the plugin's private SQLite would make nearly every plugin
non-read-only and kill auto-approval for legitimate read tools.

MCPSCHEMA-001 to MCPSCHEMA-030.
2026-08-29 12:22:59 +02:00
Renzo Beux 21f4a1241b feat(plugins): mcp:tools permission and the mcpToolProvider hook
Modelled on the hook:* family rather than on geolocation:read. The host
dispatches INTO the child for every tool call, so it needs what
HOOK_PERMISSION buys: providersOf()'s active-and-holds-the-grant check,
the second gate in invokeHook, and the acting-user binding. That last one
is the point. The user is bound host-side from the supervisor's
invocation map, so a tool called by user A reaches A's data whatever the
model puts in args.

The permission keeps no hook: prefix because the surface it opens is MCP,
not a TREK render slot, and nothing keys off that prefix. It is the first
HOOK_PERMISSION value shaped that way; `satisfies` still holds.

One commit, because it cannot be split. requireTotalCoverage makes a
HOOK_PERMISSION key with no @PluginHook consumer a boot failure, not a
test failure, and PluginRpcRegistryService.onModuleInit runs in every
buildApp() — so a half-landed version breaks every integration test in the
repo. The generator is chained to it too: it refuses to run without both
the en admin label and the CLI hint.

15s is the longest budget of any read hook. The others back a render
someone is waiting on and fall back when they time out; this one backs a
chat turn, and the plugin is likely calling a third-party API of its own.
Still under the supervisor's unused 30s default, which is a hung client
rather than a budget.

One callTool fn for every tool, since tool names are runtime data and
hookContracts() is keyed by (hook, fn).

PluginDefinition exists in two copies with no generator between them, so
both get McpToolProvider. grantGaps and the dev mock host need no change:
both read HOOK_PERMISSION, which is generated.

PLUGHOOK-010, plus the CONTRACTS row.
2026-08-29 12:22:59 +02:00
Renzo Beux 092223c259 feat(mcp): plugins:use OAuth scope
One coarse scope rather than per-plugin granularity. Which plugins are
installed is runtime data, so a per-plugin scope cannot be statically
enumerated: it would break the ${group}:${mode} derivation and the
AssertExact guards in nest-mcp-policy.ts, and its consent copy could not
be written ahead of time for a third-party plugin name. Tokens issued
before a plugin existed could never carry its scope either. Per-plugin
granularity already exists one layer down as the admin's mcp:tools grant.

'use' is a fourth mode, which widens ScopeMode. Nothing else moves:
McpAccessGroup and McpAccessMode derive from Scope, so both AssertExact
guards self-heal, and the policy's fallback branch already covers a third
mode the way it covers files:content.

The presets needed an explicit exclusion. Five of the six are written as
"everything except deletes", so plugins:use would have joined Claude.ai,
Cursor, Windsurf and Zed silently and handed out third-party tool
execution without anyone choosing it. That filter was duplicated across
the desktop and mobile files with nothing pinning it, so both now use one
shared constant, and FE-OAUTH-SCOPES-012 holds the line for the next
scope like this one.

The consent copy says what the scope does not do: the RPC host authorises
from grants frozen at construction, so this scope plus weather:read still
reaches whatever the plugin itself was granted.

Two i18n gates, not one. The dot-only group key is tracked by
i18n-parity.mjs, whose regex has no colon in its character class; the
colon-bearing label/description pair is invisible to it but is compared by
client/tests/unit/i18n/parity.test.ts, which diffs merged bundles. All 23
locales get all three keys, including the three in neither gate.

The consent glyph gets a ':use' branch. Falling through to the eye would
tell the user a tool-running scope only looks at things.
2026-08-29 12:22:59 +02:00
Renzo Beux c56521b4a4 feat(server): process-level plugin MCP tool source
Sibling of plugin-event-sink.ts: the plugins layer registers a source, and
src/mcp/tools.ts reads it as a defaulted argument. mcp-transport/ is not
touched at all, so it never learns that plugins exist and the tool-call
audit row for a plugin tool comes out of code that did not change.

Pull rather than push, unlike the event sink, because the answer depends
on the session's ctx: there is nothing a push could carry ahead of time,
and reading it per attach means activating or uninstalling a plugin takes
effect on the next session with nothing to invalidate. Same argument
channel-registry.ts makes for setPluginChannelSource.

An unset source means "no plugin tools" rather than an error, the right
answer for any app built without the plugins runtime. MCPSINK-003 pins
that case, which none of the sibling sinks covers today.

Not try/caught here: attach() already contains a throwing source and logs
which one failed, and catching again would swallow that.

The harness takes dynamicTools explicitly so suites exercise the path
without installing a process-level source.

MCPSINK-001 to MCPSINK-005.
2026-08-29 12:22:59 +02:00
Renzo Beux a6c96f4053 feat(nest-mcp): per-session dynamic tool source on McpAttachOptions
A host can now contribute tools for one session through
McpAttachOptions.dynamicTools, consulted once per attach() with that
session's ctx.

It belongs here rather than in the host calling server.registerTool()
itself, because attach() is where the onInvoke seam fires. Anything
registered outside it is invisible to the tool-call audit trail, and
handlers that run as the calling user are the last thing that should sit
in an audit blind spot.

The source is synchronous: attach() runs inside the initialize request,
so an awaited source would put a third party's round trip in front of
every session creation.

Two properties carry the weight, both security ones:

- Names are reserved against every registered entry, not against what
  this session was granted. Reserving against the session would mean a
  token scoped to trips:read has no create_trip attached, so a
  contributed tool could take that name for exactly the callers holding
  no scope for it.
- Nothing a contributor does can make attach() throw. Hosts call it
  outside their request try block, so an escape is a 500 on every
  initialize and one bad contributor takes MCP down instance-wide. The
  source is contained once, each entry again. Both live throw sites are
  covered: the SDK duplicate-name throw, and the policy-less declarative
  access throw that registered entries only avoid because validate()
  pre-checks them at boot.

ToolOptions.inputSchema widens to ZodRawShape | McpZodSchema. The SDK has
accepted either since it normalises both to one object schema, and a
whole schema is what lets a host advertise one it did not author (via
.meta()) without converting it to Zod first. The arms are disjoint, so
nothing else moves; one test cast in decorators.test.ts records which arm
that decorator declared.

DYNTOOL-001 to DYNTOOL-014.
2026-08-29 12:22:59 +02:00
Renzo Beux b4f00571dc refactor(mcp): move invalidateMcpSessions into sessionManager
The body was a hand-inlined duplicate of sessionManager's own closeSession
helper, so it moves next to revokeUserSessions and reuses it. The barrel
re-exports it, leaving admin.service.ts and admin.controller.ts untouched.

The reason to move it is that callers outside src/mcp/ are coming. index.ts
evaluates readEnv().mcp at module scope and installs the sweep interval, so
importing the barrel from a domain module drags both into every test file
that mocks app-config with a partial export set, which is the hazard kill-switch.ts
documents. sessionManager.ts has no module-scope side effects.

The log line loses "due to addon state change"; addons stop being the only
thing that invalidates a session surface.

SESS-017 to SESS-020 cover it.
2026-08-29 12:22:59 +02:00
Maurice c8c11fdb7a fix(places): pass the mapped candidate to the SQL half, and state where the two still differ
The importer's parsed item spells the provider id `googleFtid`; the match rule
reads `google_ftid`. `isPlaceDuplicate` was handed a mapped object, but
`findDuplicatePlace` got the raw one, so its id strategy was always empty. The
name then decided the match, and a second place sharing that name could take the
ftid belonging to the renamed one. One candidate now serves all three calls.

Sharing the strategy list shares the ORDER, not the comparison, and two
differences survive it. SQLite `lower()` is ASCII-only where JavaScript's is not,
so a row stored as CAFÉ CENTRAL does not match the candidate Café Central in SQL
while it does in memory. The coordinate fallback used to paper over that for a
named candidate, sometimes with the wrong row. And `buildDedupSet` collects
coordinates only for unnamed rows while the coordinate query considers every row,
which is the answer `findMatchingPlaceId` wants and the opposite of what the
in-memory half says. Both are now written down where someone will read them, and
pinned by tests, rather than implied away.

PLACE-SVC-028e covers the backfill landing on the right row. PLACES-SVC-014 to
-017 cover the id beating a misleading name, both remaining divergences, and the
tolerance as a number rather than as a reference to itself.
2026-08-29 12:22:46 +02:00
michael-bohr 84b51a8af5 refactor(places): one door onto the place-matching rule
Two consumers decide whether a candidate place is one the trip already has,
and they spelled the rule out separately: the bulk importers ask an in-memory
set (isPlaceDuplicate), while findDuplicatePlace asks the database because its
caller needs the matched row's id. The two had drifted — the SQL copy falls
back to a coordinate match for a NAMED candidate, which the in-memory copy
deliberately never does, since that would merge the restaurant and the bar at
the same address.

The order now lives once in @trek/shared as placeMatchStrategies(), and each
consumer is a mechanical interpreter of it: provider id (the only part that
survives a rename, #1550), then name, then coordinates and only when there is
no name.

Adds PlacesService.findMatchingPlaceId as the public door for an importer that
has to link to the existing place rather than merely skip the candidate.
findDuplicatePlace stays private behind it — it also returns google_ftid for
the bulk importer's backfill, which is that caller's business and not part of
"which place is this?".

No behaviour change for either existing caller. The coordinate branch is
currently unreachable from the one call site, which sits inside an
isPlaceDuplicate() guard that has already matched on name and returned; the
drift only bites once something calls it standalone.

COORD_DEDUP_TOLERANCE and externalIdsOf move to shared and are re-exported from
places.helpers, so collections.service and places.service keep their imports.
2026-08-29 12:22:46 +02:00
Maurice 4854377603 fix(oidc): stop the login page signing a user back in against their will
Two reports, one line: the automatic bounce to the provider in useLogin,
which fires whenever password login is off. Both reporters are on an
OIDC-only install, which is what arms it.

#2126, the redirect loop. The re-entry guard sat inside the oidc_code
branch, but the exchange strips oidc_code from the URL before it
navigates away. Any re-run of the effect during that window - and t is a
dependency, so a language change is enough, which every non-English
install does on the login page - read an empty search, missed the guard,
fell through to the config probe and bounced to the provider. The
provider answered silently, landed back on /login?oidc_code=, and round
it went. Each pass mints a valid session, which is why closing the tab
and reopening lands the reporter logged in. The guard is now the first
statement of the effect, so no re-run can reach the redirect.

#2123, logout that logs you back in. The brake for the bounce was
noRedirect in router location state, and it does not survive the trip:
logout flips isAuthenticated on a sync lane while the router defers its
location update, so ProtectedRoute renders once on the old route and
emits <Navigate to=/login replace> with no state, overwriting the entry
the logout handler had just pushed. Location state cannot cross a full
document load either, and api/client.ts performs one on any 401. The
fact now lives in sessionStorage, set in authStore.logout() so all seven
sign-out call sites are covered, and cleared on any successful auth and
on the manual SSO links. Per tab, so a fresh tab still single-signs-on -
that being the point of single sign-on.

Both tests were confirmed red without their fix and green with it. The
first needed rewriting to do that: the obvious version held the page
mounted past the window the race lives in and passed either way.

Not included: RP-initiated logout at the provider. TREK clears its own
session correctly, and ending the provider's is a separate decision -
it needs an end_session_endpoint the provider may not advertise, a
post_logout_redirect_uri registered on their side, and an answer to
whether signing out of TREK should sign you out everywhere.
2026-08-29 01:30:26 +02:00
Maurice 8069b88057 test(admin): wait for the migration POST instead of the save toast
FE-ADMIN-STOR-036 went red on a loaded runner while every assertion in it
passed on the run before. The two storage tests that capture the migration
POST body read it immediately after awaiting the "Storage configuration
saved" toast - but that toast comes from the PUT, and AdminStoragePanel fires
the migration POST from a later effect reacting to the refreshed admin.state.
The two are not the same tick, so reading the body straight after the toast
only held while that effect happened to flush first.

Both now await the body through waitFor. A genuine regression still fails,
with the same assertion error, once the wait times out.
2026-08-29 00:51:43 +02:00
Maurice 44a117390b test(maps): stub maplibre-gl-leaflet globally
jsdom has no WebGL, so the real vector basemap layer can never work in the
client tests. More immediately: several map tests hand react-leaflet a partial
useMap() stub with no addLayer on it, which was harmless while the basemap was
a stubbed <TileLayer> and is not now that it is a real layer - addTo() reaches
for map.addLayer and rejects into the void. 79 unhandled rejections, every
assertion still green, and vitest fails the run on those.

Stubbed in the setup file for the same reason ResizeObserver is: the capability
does not exist in this environment. The tests that assert on the layer register
their own mock, which takes precedence.
2026-08-29 00:51:43 +02:00
Maurice b401da2264 fix(maps): default the Leaflet basemap to OpenFreeMap
CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile
on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves
a self-hosted instance needing an email exchange with CARTO before its maps
look right, and every default in the client still pointed at CARTO. Default
to OpenFreeMap Positron instead: no key, no account, no request limit,
commercial use allowed, attribution required.

OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre
style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL
canvas into Leaflet's own tile pane, which leaves everything above it alone -
markers, GeoJSON, clusters, plugin layers, the custom panes, and the click
handling, since Leaflet's CSS already puts pointer-events:none on that layer.
maplibre-gl still arrives only through a dynamic import, so no chunk that
draws a map grows by a megabyte.

Switched: the trip planner, the MapView fallback every preview goes through,
the journey map, collections, the atlas, the shared trip page and the offline
tile prefetch. OpenFreeMap Positron and Bright join the tile presets in
settings, the admin defaults and both mobile screens.

A CARTO template that is already saved keeps working when a key backs it -
that stays a supported choice. Without a key it is not drawn at all:
resolveTileUrl falls back to the default rather than to a watermark, so an
instance that upgrades with one in its settings sees the new basemap
immediately and needs no migration. The stored value is left as it is, so the
Map settings tab still shows what was chosen and the warning underneath it
explains why - one sentence appended, in all 23 locales.

OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles
is wrong and a licence problem. attributionForTile() derives it from the URL
rather than from a flag at each call site, and the shared trip map, which
carried no attribution at all, now gets one.

Offline maps keep working and get cheaper. A vector style is four things
instead of one, so sync/glPrefetcher.ts walks the style document, its sprite,
the Latin glyph ranges of each font stack it uses, the TileJSON - the planet
path is versioned, so guessing it answers 200 with an empty body - and then
the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is
2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177
requests and 32.1 MB and stays sharp above that, because MapLibre scales the
z14 tile rather than asking for another. The Workbox rule for
tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache,
so browsing tops a downloaded region up instead of filling a second copy
beside it - a versioned tile path can never be stale. Clearing offline maps
clears it too.

The atlas loses its second raster layer. It existed to warm the HTTP cache of
neighbouring zoom levels, which a vector basemap does not need; a second one
would only cost a second WebGL context. OpenFreeMap has no label-free style,
so its labels are switched off on the live map instead - they are all symbol
layers, while borders and coastlines are line and fill - re-armed on
style.load so a theme switch keeps them off.

Theme changes call setStyle in place rather than rebuilding the layer: a swap
per toggle would leak a WebGL context, and browsers cap those in the low
teens.
2026-08-29 00:51:43 +02:00
Maurice 1d12535b1b fix(map): position place pins in the map's render cadence on MapLibre
The place pins visibly swam back and forth while the map was dragged and only
settled onto their real position when it stopped. The clustered bubbles never did,
and that is the clue: those are drawn inside the canvas, the pins are HTML.

A library Marker repositions itself on the map's 'move' event, which fires once per
pointer sample, while the canvas is redrawn once per animation frame. When the style
is heavy or tiles are still streaming, the pins therefore run ahead of the map they
are supposed to be pinned to.

They now live in their own layer inside the canvas container and are positioned from
the map's own 'render' event, so pin and map move on the same clock. Mapbox keeps the
library marker: its terrain path needs the altitude handling that comes with it, and
the drift was never reported there. Clicks, hover and dragging a pin onto a day are
untouched — they hang off the element, which is the same element as before.
2026-08-28 22:21:01 +02:00
Konstantinos Thermos 9e0fb19e79 fix(studio): keep the auto layout inside the book contract
Auto layout wrote journey data straight into the document without holding
it to the caps the contract sets, and the save path sends the document
unvalidated - so the route refused the whole body with a 400 and the
editor reported "couldn't save" for the rest of the session, because
autosave kept offering the same rejected book.

The everyday way in was the itinerary list: thirteen stops a page over
two pages is eighty elements on a spread capped at sixty, so a run of
twenty bare stops was unsavable and a longer one put two spreads over.
Sixty was below what this app's own layouts draw, which makes it a bug
with a constant in front of it rather than a limit, so the cap goes to
ninety and the page keeps the design somebody chose. It stays a
serialisation limit: the largest document the contract now admits is
under four megabytes against the book route's eight.

A long journal entry, a long place name, a journey past eighty countries
and a long title each did the same thing on their own, and a bound story
did it on open without anyone touching Auto layout. The caps become named
exports on the contract and every producer builds against them, so the
two cannot drift apart again - which is also why the stations chunk is
derived from the cap and the salvage fixture counts off it.

Fixes #2085
2026-08-28 22:01:06 +02:00
Maurice 8f6d5fd21f fix(dashboard): stand in for the trips and the stats while they load
The dashboard rendered its finished empty state while it was still fetching: no
hero, no cards, and a stats row reading zero everywhere. On a slow connection
that reads as "my trips are gone" rather than "not here yet", which is what the
report describes.

The stats row was the worse half. It comes from a separate call that carries no
loading flag at all, so every tile fell back to its zero until the response
landed. A dash placeholder says "not yet" where a 0 says "none".

The skeletons already existed. SpotlightSkeleton and TripCardSkeleton were
written for this page, described as matching its layout, and never wired up:
JourneyDetailPage was the only consumer of that module.

Fixes #2115
2026-08-28 21:20:59 +02:00
Maurice 71ff191bb4 fix(vacay): keep guest accounts out of the invite pickers
Guests are trip-scoped accounts. Trip Members excludes them from its picker and
refuses them on the write path, and Collections does the same, but Vacay's two
directories did not: a guest stayed selectable in a plan's Invite User dialog
and in the shared-calendar picker, including after being removed from the trip
it was created for.

The queries are server-side, so the filter belongs there rather than in the
client, which never received the flag to filter on. Both write paths get it too:
the picker only hands out a list, the id comes back from the client, and the
Vacay MCP tools reach the same two service methods.

Fixes #2112
2026-08-28 20:11:28 +02:00
Maurice 63a57b9269 fix(atlas): stop counting address abbreviations as countries
The Atlas hero counted more countries than the continent bars below it drew,
and more than the dashboard widget. The extra ones were invented: a place
without coordinates fell back to parsing its address, and any two-letter
uppercase segment at the end became a country code. "New York, NY" produced NY,
"Toronto, ON" produced ON.

Those phantoms only ever showed up in the one number. The continent strip files
an unknown code under 'Other' and never draws it, the map skips codes it cannot
resolve, and the dashboard builds its set from stored regions instead, which is
why it stayed right.

Filtering against a list of valid codes would not have been enough, and would
have hidden the worse half of this: 26 US state abbreviations are real ISO
country codes. CA is California far more often than Canada, DE is Delaware, IN
is Indiana. The existing comment above resolveCountryCode already describes that
collision and solves it by trusting coordinates first and sanity-checking the
address against the country's own bounding box. The gap it names in its last
line is this bug: with no coordinates there is nothing to check against.

So the bare-code branch is now only offered to callers that have coordinates. A
spelled-out country name still resolves either way. A lone "NY" or "CA" resolves
to nothing, which is the honest answer.

Fixes #2111
2026-08-28 20:11:28 +02:00
Maurice 4f15d87cbe fix(oidc): drop the old provider's avatar and identity when the IdP changes
Pointing the instance at a different OIDC provider left two things from the old
one on every account. The avatar kept a URL on a host this instance no longer
talks to, so it rendered as a broken image and stayed that way: the sync only
ran when the new provider sent a picture claim, and having no picture for a user
is a perfectly normal answer.

The account also stayed pinned to the old provider. Login still worked, because
the verified-email lookup finds the row, but the relink sat behind a check for a
missing sub, so oidc_sub and oidc_issuer never moved. The SSO note in settings
went on naming a provider that was gone.

An uploaded avatar is a bare filename rather than a URL and is left alone on
both paths, exactly as before.

Fixes #2110
2026-08-28 20:11:28 +02:00
Maurice adf88e1d61 fix(reservations): accept a booking that starts and ends on the same day
A permit valid for one day has no clock on either end. isEndBeforeStart filled
both missing times with midnight and then compared strictly, so start and end
came out equal and the form refused it: inline error, dead save button, toast
on submit.

It also made existing rows uneditable. A booking whose end is a bare date on the
start day is exactly what the booking import writes, and what the mobile sheet
has been allowed to save since 72a82b3c. Opening one of those on the desktop
left the save button dead with no way to clear it.

With a time missing on either side the booking is all-day, so only the dates are
compared. An end date before the start is still refused, with or without times,
and two explicit clocks still have to be in order.

The hotel guard comes along for the same reason mobile carries it: the date
panel is hidden for hotels, so switching type after typing dates left the button
disabled with its explanation inside the hidden block.

Fixes #2107
2026-08-28 18:34:47 +02:00
Maurice 910c62ea90 fix(transit): show the stop you get off at instead of the walk that follows
Every row of an expanded search result is anchored at its leg's start: the time,
the stop name, the platform. A leg's arrival appears as the next row's heading,
and the last one gets the closing row. The walking legs broke that and printed
their DESTINATION, which is the stop the next row already names.

MOTIS brackets every journey in walking legs, so the last leg of a real
connection is the walk from the arrival station to wherever you were headed.
Its origin is the stop you get off at, and because that row printed the
destination instead, the station had no row anywhere in the card and vanished.
The same branch blanked the time cell, so the train's arrival time went with it.
At the other end the access walk swallowed the origin the user had picked.

Rows now read from.name for every mode, and the walk says it is a walk in its
badge row instead. The platform tags along, which gives the exit row the arrival
platform it was already carrying. The time falls back to scheduledTime the way
the save path does, since a schedule-only feed would otherwise leave the row
that matters without a clock.

Fixes #2106
2026-08-28 18:34:47 +02:00
Maurice 6c7e257c17 test(plugins): cover the mock double and the child proxy for addEntryPhoto
The handler had eight cases, its two doubles had none, which Sonar counted as
new code without coverage. The mock is the more useful of the two to pin
anyway: an importer developing against it should hit the same refusals it will
hit in production, so the SVG, the missing extension, the empty payload and the
size cap are all asserted against the double rather than only against the host.
2026-08-28 18:04:25 +02:00
Maurice 661777f81f feat(plugins): let a plugin attach a photo to a journal entry
A journal importer can create the journey, create its entries and write their
stories, and then it stops: there was no way to give an entry a picture. The
seven journal.* methods cover text and nothing else, so an importer reading an
export archive could only upload its photos as trip files, which live on a trip
and never reach the journey's gallery.

journal.addEntryPhoto takes the bytes. Deliberately not a link to an existing
gallery photo and not a provider asset: an importer holding an archive has
neither, it has a file it just unpacked.

The access decision stays where it already was. addPhoto answers null unless the
acting user may edit the journey the entry belongs to, and null becomes
RESOURCE_FORBIDDEN here, so a plugin reaches exactly the entries its acting user
does. Everything the REST upload refuses, this refuses too: images only and no
SVG, the operator's allowed-file-types setting, 10MB decoded, demo mode. The
stored filename is the host's, never the plugin's, because the gallery dedupes
on file_path. The object is written before any row points at it and removed
again when the link is refused.

JournalRpc moves into its own module for this. It now needs StorageService and
the EXIF backfill out of MemoriesModule, and JourneyDomainModule exists
precisely so places, assignments and the plugin host can reach a few journey
functions without dragging both photo providers into their graphs.

Discussion #1365
2026-08-28 18:04:25 +02:00
Konstantinos Thermos 1c5c3ae983 fix(atlas): retile the map instead of rebuilding it when the CARTO key lands
useTileUrl returns a new string once carto_api_key reaches the settings
store, which happens after the first render. That string was a dependency
of the effect that *builds* the Leaflet map, so the key arriving tore the
map down and built a new one.

Two things break in that window: Leaflet finishes a queued redraw against
the canvas renderer whose map has gone ("Cannot read properties of
undefined (reading 'save')"), and the country layer never comes back —
its effect only re-runs when its own data changes, so the rebuilt map
shows tiles and nothing else.

The template belongs to the tile layer, not to the map. Hold the layers
in a ref, read the template through a ref inside the build effect, and
setUrl them from an effect of their own. Same fix in JourneyMap, where
cartoKey and mapTileUrl sat in the dep arrays of the effects that draw
every marker and track.

Fixes #2097
2026-08-28 16:04:38 +02:00
Maurice 5a7b8d75aa feat(api): add GET /api/v1/stats for dashboard widgets
Homepage's customapi widget and everything shaped like it want a handful
of numbers from one request; /api/v1/trips hands out the rows, but a
widget maps fields, it cannot aggregate a list.

The figures come from AtlasService.getTravelStats, the same call behind
the dashboard's passport card, rather than from a second count. Visited
countries carries years of rules — countries reached only by a flight,
layovers that do not count, countries hidden by hand — and a second
implementation would disagree with the card next to it within a release.

That is also why the controller sits in atlas/ and not in public-api/:
PublicApiModule stays a leaf, so the route comes to the data instead.
TravelStatsController next door already does this for
/api/auth/travel-stats. ApiTokenGuard is provided locally rather than
imported with PublicApiModule — @UseGuards instantiates a guard in the
declaring controller's module, so an export would still leave its
TokenService unresolvable here.

last_trip means the most recently started trip, not the most recently
created one, and is null while every trip is still ahead.

Closes #1367
2026-08-28 15:23:05 +02:00
Maurice 7cbd4e5c42 test(auth): cover the api-token routes
The three api-tokens endpoints landed without tests, which took
src/nest/auth/** under its coverage floor. Same four paths the
mcp-tokens block above already pins, plus the rate limit: unlike MCP
tokens these are not refused on a managed instance, so the limiter is
the only thing bounding a scripted caller.
2026-08-28 15:23:05 +02:00
Maurice 1e1a80c199 feat(api): expose shortlisted places, detached bookings and the bucket list
Reading the surface back against real data turned up three things it dropped
without saying so.

Places with no day were invisible, because the payload only reached places
through day_assignments. On a live instance that is half of them, and they
are the ones carrying a coordinate and no claim about when — exactly what an
integration can act on. They come back under `unplanned_places`, minus the
hotel, which is already reported as an accommodation.

Bookings with no day were dropped by the day filter. reservations.day_id is
nullable and deleting a day detaches its bookings rather than removing them,
so this is not a hypothetical.

The bucket list had no endpoint at all. It hangs off the user rather than a
trip, so it gets its own, and it answers whether or not the Atlas addon is
switched on: the addon decides what TREK shows, not what a key can read.

Also fixes ?include=notes returning the bare trip: places, notes and
reservations are reported on days, so asking for one now implies days
instead of quietly answering with nothing.

The module stays a leaf. Reaching for AtlasService pulled AuthModule and the
storage registry in behind it and broke the surface's own boot, so the one
SELECT lives here like every other query in the file.
2026-08-28 15:23:05 +02:00
Maurice d559fdda1d feat(settings): give API keys their own section
Minting a key for other software meant finding it under MCP, behind a
heading about AI assistants and gated on an addon it does not need. It now
sits on its own in Settings, above the MCP block, and works on an instance
with MCP switched off.

Deliberately separate state rather than another branch of the tab's shared
hook: the section has to stand on its own, and that hook is already carrying
forty fields through three levels of props.

Translated into all 23 languages.
2026-08-28 15:23:05 +02:00
Maurice 4b120d39c8 feat(api): separate API keys from MCP tokens, add travellers
An MCP token drives every assistant tool; an API key reads trips over HTTP.
Handing the same credential to a third-party integration gives it the whole
MCP surface, so the two are now separate kinds on `mcp_tokens` and the kind
sits in the WHERE clause — a token of the wrong kind is indistinguishable
from one that does not exist.

Also adds `travellers` to the trip payload: display name and an owner flag,
no ids and no email addresses. An integration key reads its owner's
itinerary; it is not a way to enumerate the people around them.

Documents the surface in the wiki, since integrators need something to read
that is not the source.
2026-08-28 15:23:05 +02:00
Maurice 9cddf683a5 feat(api): rate-limit /api/v1 and report when a trip last changed
Two gaps from the first pass, both about a client that polls.

Rate limiting is keyed by user rather than by IP. A self-hosted integration and
its owner's browser routinely leave the same address, and an IP bucket would let
one starve the other; 120 requests a minute is generous for a sync surface and
still bounded, so a runaway poll degrades its own integration instead of the
instance.

updated_at now ships with every trip, and its docblock says plainly what it is
not: a content timestamp. It moves when the trip's own fields change — title,
dates, currency, cover — and stays put when a place, a day or a note is edited,
because the child tables carry no updated_at of their own. That also rules out
the ?since= filter this was meant to enable: it would silently hide every trip
whose itinerary changed, which is worse than no filter at all. Making it honest
needs a schema change, not an API field, so the field is documented for what it
is and the filter is left out.
2026-08-28 15:23:05 +02:00
Maurice fd6381c814 feat(api): add a versioned read-only public API for integrations
TREK's REST surface is internal: it answers a session cookie, carries no
version, and changes with the client. That is fine for our own frontend and
useless for anyone building against us — the Dawarich integration people have
been asking for since April needs something it can rely on not moving.

So this adds a small one rather than opening the existing 549 endpoints:

  GET /api/v1/trips           the trips the caller can reach
  GET /api/v1/trips/:id       one trip, ?include= picks the sections

Auth reuses the trek_ tokens users already manage under Settings, via Bearer
or X-API-Key. No new credential type, no new table — the guard calls the same
hash lookup the MCP transport does. It deliberately refuses a session JWT and,
for now, OAuth bearers: a credential whose scopes this surface does not yet
interpret is worse than one it rejects outright.

Access is decided per trip against canAccessTrip, the same owner-or-member
predicate as everywhere else, and a trip the caller may not read answers 404
identically to one that does not exist — a 403 would confirm the id is real and
turn the endpoint into a way to count someone else's trips.

The payload is not the rows. Ids, foreign keys and order_index stay inside;
accommodation day ids resolve to ISO dates, places arrive in planned order, and
every dated thing carries its date so a consumer can join on days rather than
on ids it has no way to look up.
2026-08-28 15:23:05 +02:00
Maurice d2402c798c fix(llm-parse): read an arrival that arrives as a bare clock again
fixArrivalDate normalized the arrival and then looked for the time behind a
leading date. The model usually sends the arrival as a bare clock, "13:00" with
no date on it, and slicing ten characters off that leaves nothing to match, so
the function returned early and stopped deriving the arrival date at all.

Try the bare 12-hour form first, then fall back to the normalized string, which
covers both shapes: "13:00", "01:11 pm", and a full date-time.
2026-08-28 14:25:08 +02:00
Maurice d091f4a815 fix(shared): read the regex groups defensively in the datetime helper
shared compiles with noUncheckedIndexedAccess, so a capture group is
string | undefined there even when the pattern guarantees it. Pull the groups
through optional access with explicit fallbacks instead of indexing into the
match, which also drops the two throwaway locals.

No behaviour change: same inputs, same outputs, the spec is untouched.
2026-08-28 14:25:08 +02:00
Maurice 130272cfb0 fix(booking-import): stop writing an unreadable type down as a hotel (#2076)
When neither the keyword table nor the model produced a type, the router wrote
"hotel" and cast whatever string it got to FlatType without checking it. A hotel
is a booking type, so a ferry voucher imported from the Transport tab opened the
booking form, which offers six chips and not one transport among them, and
"other" was the only honest pick left. An unrecognised string got through the
cast too and then vanished silently in nuExtractToKiReservations.

The type is validated against FLAT_TYPES now. When it cannot be read the item is
still extracted with the lodging shape so it survives mapping, but it says so,
and the tie-breaker #2080 added finally has something to fire on: isUnplaceableItem
only ever matched a type outside both form's vocabularies, and the server can
only emit its own eight, so on a real document it never matched at all.

The tab travels with the job instead of sitting in component state. The parse
deliberately outlives navigation and reload and the review is triggered by the
global widget on any page, so by the time it ran the state had remounted back to
its default and the context was gone.
2026-08-28 14:25:08 +02:00
Maurice 016fdeec64 fix(booking-import): read a printed 12-hour clock instead of slicing it (#2094)
splitIso reached the time by taking characters 11..16, so "2026-06-11T01:11 PM"
became 01:11. The hour survived and the half of the day did not, which is why
AM and 24-hour times in the same document imported correctly. The arrival then
sorted before the departure and the router rolled it onto the next day, so a
three-hour hop was rendered as an overnight.

The client has done this properly since #1725; this is the server half, which
was never pulled across. normalizeLocalDateTime rewrites a value only when it
actually finds a meridiem, so a date-only value, a value carrying an offset and
anything the parser does not know come back byte for byte.

The mapper has only two exits for a date, toIsoString and splitIso, so this
covers all eight types and every emitted field at once. It also stops
sameConnection comparing a NaN timestamp, which silently merged unrelated legs
into one booking.
2026-08-28 14:25:08 +02:00
Maurice 15588d20c9 fix(feeds): keep a staged booking out of the public feed and the shared trip
The ICS feed is public with a token that never expires and prints the
confirmation number into the DESCRIPTION, and the shared-trip payload does a
SELECT * over reservations with share_bookings on by default. Neither filters
anything, so whatever lands on a trip is published the moment it is written.
That is fine while a person types every booking in, and it stops being fine as
soon as something writes on their behalf.

reservations.ingest_state is that gate. It defaults to live, SQLite backfills
every existing row on the ALTER, and no current writer names the column, so
every booking visible today stays visible. Nothing writes staged yet.

The predicate takes its table alias because share.service.ts queried
reservations without one. A stay is withheld only when bookings point at it and
all of them are staged: a hand-added hotel has no booking at all and has to keep
showing up.
2026-08-28 14:25:08 +02:00
Maurice 8641788de0 docs(wiki): the new MCP surfaces
Files, settings, calendar feeds, invite links, the AirTrail import, provider
photos, help and the addon list. Also says why accepting an invite and
reading photo bytes have no tools.
2026-08-28 14:20:02 +02:00
Maurice 66530a074f feat(mcp): register the new surfaces, and point the model at the units
Ten new controllers in the test registry, and one line in the session
instructions: without it nothing tells an assistant to read the display
settings before rendering a temperature, which is what makes that tool worth
having.
2026-08-28 14:20:02 +02:00
Maurice 8a846e4b91 feat(mcp): read the warnings plugins raise about a trip
A plugin warning is a plugin telling the user something is wrong with their
trip, and get_trip_summary omitted them, so an assistant reviewed a trip
from a strictly smaller picture than the user was looking at.
2026-08-28 14:20:02 +02:00
Maurice fa67a29e76 feat(mcp): find provider photos and attach them to a journal entry
The whole memories domain had no tools, so the Immich and Synology
libraries were invisible even though the attach body is plain JSON and the
photos never pass through the call. Search, album listing, album contents,
and the attach.

The routes that stream photo bytes stay out: those are img targets.
2026-08-28 14:20:02 +02:00
Maurice dd469f7d1a feat(mcp): import flights from AirTrail
TREK's own bulk travel-history importer, and the closest thing in the
product to what this whole branch is about. Both halves are server-side
JSON, so neither was ever browser-bound; they simply had no tools. The list
tool's output carries what the import tool needs, so a model can run the
pair without a detour through the UI.
2026-08-28 14:20:02 +02:00
Maurice fa79923663 feat(mcp): calendar feed tokens and the trip invite link
export_trip_ics is a snapshot of one trip, so subscribing was out of reach
and the all-trips feed had no counterpart at all. Both feed tokens can now
be read, minted, rotated and revoked.

The invite link is the one that grants membership, which create_share_link
is not, and the descriptions say so in both directions so a model does not
reach for the wrong one. The two consumer routes stay out: the ics URLs are
what gets subscribed to, and accepting an invite is a human act.

test-db's reset list was missing trip_invite_tokens, so a leftover invite
re-attached itself to the next test's trip once ids restarted.
2026-08-28 14:20:02 +02:00
Maurice 9ed35d182f feat(mcp): the bundled help pages, and which addons are on
131 of 201 tools carry an addon gate, and the registry drops an entry when
either the addon gate or the scope gate fails: one filter, one outcome. The
session instructions tell the model to check what is available before
assuming a tool exists while giving it nothing to check with. list_addons is
that something, and it is the difference between a confused apology and
'ask your admin to enable Budget'.

The help routes were already public, so the two reader tools add no scope
surface at all. The asset route stays out: it serves bytes to an img tag.
2026-08-28 14:20:02 +02:00
Maurice ed67a5e9f8 feat(mcp): read and change display preferences
An assistant rendering a temperature, a distance or a clock time was
guessing: the session instructions named only the trip currency, and nothing
could read what the user had picked.

The write works off an allow-list rather than off the settings row, because
that row also holds the webhook URL, the ntfy token and the Mapbox, Carto
and LLM keys, and getUserSettings hands some of those back in cleartext.
Everything outside the list is refused, including a key nobody has heard of
yet. The guard test asserts against the service's own encrypted and masked
sets rather than a copy of them, so a sixth key added there fails it.
2026-08-28 14:20:02 +02:00
Maurice 6270495327 feat(mcp): read and organise the documents on a trip
Often the travel history is the uploaded PDFs, and an assistant could not
learn that a trip had any: there was no files.mcp.ts, no files scope, and no
files section in the trip summary.

Six tools: list, read contents, set description and primary link, add a
link, remove one, list them. Reading contents rides files:content rather
than files:read, because listing what a trip carries and reading what is
inside a booking PDF are different privileges.

The content read is not a second copy of the plugin path. readContent moved
out of files.rpc.ts into FilesService and both callers use it, so the 10 MB
cap, the storage-layer read and the off-thread decode stay in one place. The
RPC keeps its own error wording, which its tests still pin.
2026-08-28 14:20:02 +02:00
Maurice 42ba5d4849 fix(mcp): keep the categories module out of the auth graph
The admin gate on the three new category tools reached for AuthService, which
drags the storage registry in behind it, and the categories e2e builds a
container around one module against a two-table database. It stopped booting.

budget/ already solved this: isDemoUserId reads RuntimeEnvService and the
users table directly, and its module comments say why AuthModule is absent.
Categories now does the same, which also takes eight hand-built collaborators
out of its unit test.

The e2e gained RealtimeModule, which is global in the app graph but not in a
partial container, the way days.e2e.test.ts already imports it.
2026-08-28 14:20:02 +02:00
Maurice ab3751761c feat(mcp): files and settings scope groups
Two domains an assistant cannot reach at all today need somewhere to hang,
so this is the credential half on its own, ahead of the tools.

files:content is a third mode rather than a second group. Listing the
documents on a trip and reading the bytes inside a booking PDF are different
privileges, and the plugin host already draws that line between
db:read:files and db:read:files:content. It is not implied by files:write,
the same way journey:share is not implied by journey:write; the policy's
fallback branch covers both.

settings:write is scoped to display preferences. Settings also holds the
webhook URL, the ntfy token and the Mapbox, Carto and LLM keys, so the tools
that ride this scope work off an allow-list rather than off the settings
row.

34 scopes across 16 groups, translated into all 23 languages.
2026-08-28 14:20:02 +02:00
Maurice aa251cdb8e test(mcp): construct CategoriesMcp with the deps its tools now need
The three admin category tools gate on AuthService and the shared guards, so
the hand-built registry the MCP harness uses has to hand them over too.
2026-08-28 14:20:02 +02:00
Maurice 2ba10a9126 docs(wiki): the tools and fields added across the MCP surface
Also drops the em dashes from the earlier entries in this branch.
2026-08-28 14:20:02 +02:00
Maurice 44c2573abb feat(mcp): trip length, reminders, cover search, and the member permission
A dateless trip made by a tool silently got seven days, and reminder_days
was stuck on the default of three with no way to turn it off. Cover images
had no search at all.

add_trip_member and remove_trip_member hardcoded an owner check while REST
consults the configurable permission matrix and the plugin RPC does too:
three surfaces, two behaviours. They now use the same permission call
update_trip already made, so an instance that lowered member_manage behaves
consistently. The guest tools stay owner-only, because their REST routes are
owner-only by design and document it.
2026-08-28 14:20:02 +02:00
Maurice 4f98c5ea44 feat(mcp): collection membership, bulk visited marking, and upcoming bookings
Asking whether a place is already in one of your lists took a walk over
every collection and lost the coordinate tolerance the server matches on.
Marking a trip's places visited across every list holding them was one call
per place.

The dashboard's next-booking view is a three way union that folds in
synthetic check-in and check-out rows, so rebuilding it from per-trip
summaries silently drops the accommodations.
2026-08-28 14:20:02 +02:00
Maurice 9e21eb5d10 feat(mcp): note colours, note order, a note website, and category CRUD
A day note could not be coloured or positioned, so the service default of
9999 pinned every note an assistant wrote to the bottom of the timeline. A
collab note could not carry the website the card renders.

Categories could be assigned by bulk_update_places but never created: the
whole file was one read tool and a resource. The three new tools are
admin-only, matching the REST routes exactly.
2026-08-28 14:20:02 +02:00
Maurice 5d0fac3026 feat(mcp): place pictures, POI search, GPX export and a biased place search
image_url was in no places tool although the service persists it and the
picker in the UI sets a URL, which is not a file transport question.
search_place passed two of the service's four arguments, so an unbiased
query resolved to the wrong continent.

get_place_details gained expand for reviews and the editorial summary. It
defaults to off because that field mask bills as a Google Enterprise SKU,
and it now honours the admin kill switch the REST route checks: an instance
owner who turns Place Details off to stop the billing was still billable
through this one surface.
2026-08-28 14:20:02 +02:00
Maurice 922c60c167 feat(mcp): correct a journey entry, not just write one
location_name was settable when creating an entry and never afterwards,
which is the sharpest create-versus-update asymmetry in the surface.
Coordinates, weather, tags and the pros and cons verdict were persisted by
the insert but had no way in, so an entry written by an assistant never
placed on the journey map.
2026-08-28 14:20:02 +02:00
Maurice a5ac6b15e6 feat(mcp): resolve a coordinate to a region, and report the derived ones
mark_region_visited wanted a code like US-CA that nothing in the tool
surface could produce. The visited-region list also read the raw manual
marks while the UI reads the list derived from trip places, so the two
disagreed about where somebody had been. Travel stats gained the cities and
the distance the dashboard shows.
2026-08-28 14:20:02 +02:00
Maurice 4225d883c0 feat(mcp): half days, comp days, and the vacay plan settings
The toggle took a date and nothing else, then hardcoded a full vacation day
for the calling user, so a half day, a comp day or a day logged for another
plan member had no expression.

School holidays needed the plan flags that switch them on, and the region
list that makes them usable. That list needs care: a group region is stored
as COUNTRY|group:CODE and the reader treats a bare code as a subdivision, so
the tool now returns the string add_holiday_calendar actually wants. Belgium
and the Netherlands are the two countries this affects.
2026-08-28 14:20:02 +02:00
Maurice fed562d564 feat(mcp): bags, quantities and the sharing tiers of a packing item
Bag assignment was reachable only while bulk-importing a list, and by bag
name, never for an item that already exists. Quantity, weight and the
private flag were not reachable at all, and the whole three-tier sharing
model was invisible: an item made by a tool was always Common.
2026-08-28 14:20:02 +02:00
Maurice e0169388d3 feat(mcp): uneven expense splits, and the fields update was missing
Historical costs are rarely split evenly, and every budget tool took only a
list of member ids, which the service stores as a null amount, meaning an
equal share. members[] carries what each person owes instead.

A custom split has to reconcile before it is persisted, so the tool refuses
one whose shares do not add up in whole cents. Two ways round that check
needed closing with it: the write path drops a payer who is not on the trip
and then derives the total from what survived, and an empty payer list means
a total of zero rather than the previous one. Both would have certified a
split the stored row does not satisfy.

Also on update, which had none of them: currency (so the FX freeze is no
longer a no-op), expense_date, and a settlement currency, plus place_id on
create so a museum ticket can point at the museum.
2026-08-28 14:20:02 +02:00
Maurice f85a87e6d0 feat(mcp): reorder days and insert one mid-trip
An imported itinerary landed in whatever order the assistant appended it,
with no way to fix it afterwards: create_day only ever appended, and
days.service.insert was reachable from exactly one place in the server, the
REST controller. Because both halves were missing, inserting a day into the
middle of a trip could not be expressed at all.
2026-08-28 14:20:02 +02:00
Maurice f1bbd94f2b feat(mcp): booking link and end time on a reservation
url is persisted on create and update and rendered as a link by the
planner, and reservation_end_time is what makes a dinner run 19:00-21:30
rather than just starting. Neither was in a tool schema, so a booking
written by an assistant lost both - which for an imported itinerary is
every confirmation link it came with.

url validates through reservationUrlSchema from @trek/shared, the same
refinement the REST route applies, rather than a second hand-written one.
2026-08-28 14:20:02 +02:00
Maurice aa5002b2ed fix(mcp): keep transit out of the transport create enum
The transport form's picker has nine entries, not ten: a transit booking
carries a provider itinerary in metadata.transit and create_transit_journey
is what writes one, so letting a caller hand-make a bare transit row
produces a shape the transit UI does not expect.

Splits the one list in two - what may be asked for, and what counts as a
transport for the update gate - so a stored transit booking stays editable
through update_transport, which is where it was already reachable.

Also reports the ids set_reservation_travelers dropped instead of
returning a plain success with a shorter list.
2026-08-28 14:20:02 +02:00
Maurice 86c0c197db feat(mcp): set who is travelling on a booking
Reads have hydrated a booking's travellers since #1517; there was no way
to write them, so an imported booking could not record who was on it -
and creating a guest is only half an answer if the guest cannot then be
attached to the flight they were on.

Mirrors PUT /api/trips/:tripId/reservations/:id/travelers. The roster
filter stays in the service, so an off-trip id is dropped rather than
attached and the tool cannot be used to enumerate users.
2026-08-28 14:20:02 +02:00
Maurice 4b21a70ab2 feat(mcp): add, rename and delete trip guests
add_trip_member resolves an existing account by username or email, so a
travelling companion who never signed up could be put on a trip in the
planner and not through an assistant. A guest is assignable to budget
splits, packing, to-dos and day participants like any member, so leaving
them out of an import loses who was actually there.

Mirrors the owner-only /api/trips/:id/guests routes, including the
trip-scoped guard that keeps one trip's owner away from another trip's
guest and off a real member's user row.
2026-08-28 14:20:02 +02:00
Maurice 34522ad3bc fix(mcp): accept every transport type the planner offers
create_transport and update_transport took flight, train, car and cruise.
The picker in TransportModal has ten modes and ReservationsPanel renders
all of them, so a bus, a ferry or a taxi could be planned in the UI and
not through an assistant - which is where importing a travel history from
another service runs aground.

Both lists now come from one constant in the picker's order rather than
four literals repeated across two schemas, which is how they drifted.
legs[] stays on flight and train: the form writes metadata.legs for those
two and for nothing else.
2026-08-28 14:20:02 +02:00
jubnl d8fd2deb12 feat(sdk): allow new signing key (#2088) 2026-08-28 00:56:44 +02:00
Maurice 922f37ab52 fix(storage): ask the hook whether a storage job is running, not a stale closure
Every push to dev has been red since the 4.0.0 bump, always on the same test:
FE-MOB-MSTOR-015, where the second queued migration POSTs while the first is
still running. It passes on the PR runner and fails on the slower push one.

Instrumenting the queue effect logs the whole story:

  DEQUEUE files stateMigrations []
  APPLY refresh ["running"]
  START RESOLVED files error null
  DEQUEUE journey stateMigrations []

The refresh had already landed the running migration before the single-flight
lock was released, exactly as intended. What fires afterwards is the effect of
a commit scheduled BEFORE the POST, so its captured admin.state is the pre-POST
world, and it runs late enough that the lock is gone. The lock is a ref and is
read live; the "is one already running" test was read off that dead closure.
Under load the two drift apart and a second migration starts on top of a
running one.

So the hook answers the question instead. It keeps its world in a ref as well,
written where the response is applied rather than when the render lands, and
storageBusy() reads that. The queue effect still WAKES on admin.state, it just
no longer DECIDES on it, and the server's one-job-at-a-time rule now lives with
the hook that talks to the server rather than twice in two shells. Same shape as
the dirtyRef/savingRef the hook already keeps for the same reason.

An earlier attempt made the panels refresh again after startMigration, which was
both redundant (startMigration has awaited its own refresh since #1949) and no
fix at all: the stale read is on the next effect run, not on that promise.
2026-08-28 00:22:57 +02:00
Maurice f513881c01 ci: pin the platform order in the multi-arch manifest
The manifest list was assembled from a glob over the downloaded digest files,
which are named after the digest itself. The order was therefore alphabetical
by hash, and it changed from one release to the next: 3.4.1 lists amd64 first,
4.0.0 lists arm64 first.

That is not supposed to matter — a client is meant to pick the entry matching
its own platform — but several people updating from 3.4.1 to 4.0.0 on amd64
reported that `:latest` still resolved to 3.4.1 and only a pinned `:4` or
`:4.0.0` pulled the new image. Unraid came up twice. Content, format and digest
of the three tags are identical, so the flipped order is the only thing that
changed between the release that worked and the one that did not.

The digests are now collected per platform in a fixed order, amd64 first. That
needs the artifacts unmerged, since the per-platform folder name is the only
thing that says which digest belongs to which architecture.

Second thing the loop fixes: the glob happily built a single-platform manifest
if one of the two builds had failed to upload. It now fails loudly instead of
publishing a `:latest` that half the users cannot pull.
2026-08-27 23:28:35 +02:00
Maurice 92575c0d49 @
fix(import): stop reading tickets as lodging, and stop scroll escaping a dropdown

Two v4.0.0 reports.

The file importer classified nearly every transport document as a hotel. Its
keyword table tested lodging second, on a group that included "check-in" and
"check-out" — words printed on almost every ferry, train and bus ticket there
is — so a ferry ticket came back a hotel, and the review then handed the user
the booking form, whose six chips contain no transport type at all. That is
where "everything ends up as other" comes from: it was the only honest pick
left. Transport is tested first now, the lodging pattern asks for a word that
names lodging, and the rental pattern asks for a rental rather than for a
pick-up time that ferries and shuttles also print.

Two more in the same function. The flight path forces its type on everything it
returns, and it claimed any document with two letters followed by digits — which
is what a German rail number looks like, so an IC ticket was imported as a
flight without the keyword table ever running. It now stands down when the
document already names another kind of transport. And a type neither form can
express opened the booking form regardless; it now follows the tab the import
was started from, while a type either form knows still wins over the tab, since
one PDF routinely holds a flight and a hotel. An unreadable type reaching the
transport form arrives as transport_other rather than as a flight: a wrong
flight looks right enough to be saved unnoticed.

The other report: a dropdown inside a sheet scrolled the page instead of its own
list. The panel is portaled to document.body and positioned fixed, so its scroll
chain runs to the viewport rather than to the sheet it looks like it belongs to,
and nothing stopped it. Three portaled lists carry overscroll-behavior: contain
now — the select, the navigation menu (which reaches the phone through the place
sheet, not only the desktop inspector) and the phone day picker.
@
2026-08-27 21:35:03 +02:00
Maurice 4d6820a4d4 @
fix(planner): route a layover flight per leg, and count linked places as planned

Two v4.0.0 reports, both a surface answering a question with less than it had.

The map built its own waypoint order. It read the BOOKING position and never
expanded metadata.legs, while a multi-leg booking stores its position per leg —
so a Frankfurt to Vancouver flight via Reykjavik was dropped from the list and
the airport before it was joined to the airport after it in one driving run
across the Atlantic. It now takes the order from getMergedItems, the same one
the day plan renders, which brings the span filter and the leg expansion with
it. getMergedItems in turn had never read day_plan_position, the one slot the
map did read, which is how the two could disagree at all.

getTransportRouteEndpoints handed back the booking outermost airports for every
leg. A stop between two legs was therefore routed from the arrival airport back
to the departure one — a drive from Vancouver to Frankfurt with a cafe in
Reykjavik on the way. A leg now spans its own pair, when the endpoints line up
one per stop with the legs; anything ambiguous keeps the role lookup.

Being planned meant one thing: a day_assignments row. A hotel is linked through
its stay and a venue through its booking, and neither is ever dragged onto a
day, so both sat under Unplanned while the day header printed their names. The
three places that asked the question — desktop sidebar, map markers, phone
browser — now ask one helper that also counts a stay and a day-anchored
booking. A booking that names no day still counts as unplanned, because nothing
says when it happens, and the day plan renders nothing for it either.
@
2026-08-27 19:21:06 +02:00
Maurice 88c34f6415 @
test(calendar): cover the window paths the split added

The domain branch ratchet went from 93 to 92.3: the new hand-over code brought
branches with no case behind them. Three cases and one simplification, no
threshold touched.

Covered: a split rental whose endpoints carry no IANA zone and fall back to
their coordinates; a bare end clock, which resolves against the start date and
therefore does NOT split; and an unsplit rental with a day but no clock, whose
hand-over has nothing to place and correctly emits nothing.

The metadata parse now happens once, inside describeReservation, rather than
three times. The two extra copies were only there to hand `meta` to it, and one
of their arms is unreachable from SQLite, where the column is always text.
@
2026-08-27 18:19:33 +02:00
Maurice bbe4d62c90 @
fix(export): print the reader clock format, and split window bookings in the feed

Three v4.0.0 reports that all come down to a surface printing what is stored
instead of what the reader asked for.

The PDF (#2066) is assembled as an HTML string outside React, so no hook could
reach it and no time format was ever passed in. It printed the column: four
clock sites, all 24h whatever the personal setting or the admin default said,
and a place time typed by a 12h user came out as "3:00 PM" for a 24h reader.
Both export entry points now hand the format over the way they already hand
over the locale, and the four sites go through formatClockTime.

The journey editors (#2067) used a native <input type="time">, which paints
12h or 24h from the browser locale and cannot be told otherwise by any
attribute. Every other time field in TREK has used CustomTimePicker for exactly
that reason; the journey editor, its phone sheet and the phone place sheet were
never migrated, so the same place form obeyed the setting on desktop and
ignored it on a phone. The picker gained a disabled prop so the read-only
journey sheet keeps the affordance the native input gave it.

In the subscribed calendar (#2068), a booking that is a window rather than an
activity read as one block across its whole range: a week of airport parking
sat on all seven days. Both hand-overs now stand on their own — an hour each,
carrying what the block used to say — and the block goes. Only when the two
ends fall on different days: a same-day booking is one sitting, and a rental
with only its pickup geocoded keeps the block, which is the only thing holding
the return time and its zone at all. Rental cars were missing outright, because
the planner writes reservation_time = NULL when its optional time pickers are
left blank while still setting day_id, and nothing in the exporter ever read
the linked days. It reads them now, as the last resort, for every type — so a
hand-typed bus or train shows up as well.
@
2026-08-27 18:19:33 +02:00
Maurice eb3e0ca7c9 Merge pull request #2065 from liketrek/feat/upcoming-stays-and-status
fix(dashboard): fix the upcoming widget dropping bookings, and give it the stays back
2026-08-27 16:20:26 +02:00
Maurice 0ca4dbaa94 Merge pull request #2069 from liketrek/fix/tests-pinned-to-3x
test(server): unpin three fixtures from the 3.x version window
2026-08-27 16:20:14 +02:00
Maurice a9b0cccb74 @
test(server): unpin three fixtures from the 3.x version window

The 4.0.0 bump turned three suites red on dev, all for the same reason: they
described a world where the host was still 3.x.

The plugin install fixtures declared trek ">=3.2.0 <4.0.0" and ">=3.0.0
<4.0.0", so every default artifact became incompatible with its own host and
23 tests failed on the version gate instead of on what they were testing. The
upper bound bought nothing — the cases that exercise the gate pass their own
range — so it is gone rather than moved to 5.0.0.

The system-notice suite named thank-you-support twice. It is capped at 4.0.0
and handed over to release-4-0-0 there, so the established-user case saw a
notice it had never been told about, and the per-version recurrence case rode
on a notice that no longer applies at all. The first now reads the always-on
set out of the registry instead of naming it, and the second drives the
mechanism through a notice it pushes itself. Neither ages out at the next
release.
@
2026-08-27 16:14:25 +02:00
Maurice f15a124016 @
fix(dashboard): fix the upcoming widget dropping bookings, and give it the stays back

Three things, all in the same widget.

reservation_time holds two shapes. The booking form writes the datetime-local
YYYY-MM-DDTHH:MM; day-anchored rows, the demo seed among them, hold a bare
HH:MM. The query compared the column against an ISO timestamp as a string,
where ":" sorts above "2": every booking from 20:00 on passed the filter and
everything before it silently vanished, and the order ran off the same column.
On the demo that is why the Broadway show shows and the ferry does not. The
query now separates the date from the time before it filters, so a value with
no date falls back to the day it hangs off instead of being read as one, and
the order is by day and clock. The demo seed writes full timestamps, which is
the shape every real write path already produces.

Hotels came out of this list because a stay covers a range and a week in one
would hold a slot for the whole week. Arriving and leaving do not: they are
moments, and they are the two worth reminding someone of. Check-in and
check-out now appear as their own entries, named after the stay place and
falling back to the linked booking. The stay itself stays out. The two carry
the accommodation id, so the list key had to take the type with it.

An unconfirmed booking looked exactly like a confirmed one. The status was
already in the payload and is now a badge, on both the desktop tool and the
phone widget.
@
2026-08-27 15:38:45 +02:00
Maurice 99387812a1 Merge pull request #2063 from liketrek/main
chore: back-merge the 4.0.0 version bump into dev
2026-08-27 13:32:21 +02:00
github-actions[bot] a00b84b216 chore: bump version to 4.0.0 [skip ci] 2026-08-27 11:23:54 +00:00
jubnl 77dddd5e8a v4.0.0 (#1719)
Release branch for 4.0.0.
2026-08-27 13:22:39 +02:00
Maurice 4784a1e5dd fix(quality): clear the SonarQube findings on the 4.0.0 branch (#2062)
The day-swipe move handler mixed classifying the gesture with following the
finger, which put it at cognitive complexity 20. The classifying half is a
pure question about a gesture, so it moves out as classifyLock: null keeps
waiting for the next move, dead and h end the same way they did before.

Also reads the reduce-motion flag through dataset, seeds the closest-year
reduce with years[0] so it cannot throw if the empty guard above it ever
loosens, and compares the in-flight preview entry against undefined instead
of leaning on a promise being truthy.
2026-08-27 12:38:14 +02:00
Maurice de9bd94279 @ (#2061)
fix(plugin-sdk): stop the README heading match backtracking

`missingSections()` matched headings with `(.+?)\s*(?:<!--.*)?$`, where the
lazy body and the trailing `\s*` compete over the same run of spaces. That
backtracks quadratically: a heading line padded with 80k spaces takes about
12 seconds, 200k about 75. The gate runs over READMEs anyone can submit to
the registry, so the padding is not the author own input.

The trailing anchor comment is now cut with indexOf, and the body is matched
with its edges pinned to non-space, so nothing competes with the trailing
`\s*`. Same shape the wiki sidebar parser was rewritten to for the same
reason. Behaviour is unchanged on every heading that is not the attack:
trailing comment, unterminated comment, tab separator, padded title.

scripts/check-readme.mjs in the TREK-Plugins registry carries the same
regex and needs the same change.
@
2026-08-27 12:34:55 +02:00
Maurice 1f66ef1d32 fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile
fetched without a key since 26.08.2026, and both CartoDB presets plus the
built-in fallback are CARTO. The key is free and needs no account, so add
it as a setting rather than dropping the provider.

carto_api_key sits next to mapbox_access_token: per user in Settings/Map,
instance-wide under Admin/User Defaults, encrypted at rest, injected from
CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the
map provider, since a raster key says nothing about the renderer.

The key is appended as ?key= at render time instead of being stored in the
template, so a saved URL stays portable and survives a rotation. That is
also what reaches the four maps that pinned CARTO and ignored the user's
tile setting: atlas, collections, the public trip share and the public
journey. The two share pages read it from their token-gated payload, the
same way baseCurrency already travels.

Also lifts the release notice above page chrome. Its overlay sat at
z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither
on top nor modal. Adds a six-step layer scale to index.css and moves the
three notice shells onto it.

Closes #2054
Closes #2052
2026-08-27 12:06:55 +02:00
Maurice 521e52c853 fix(collab): harden the link preview fetcher (#2058)
The preview endpoint takes a URL from whoever is typing in a trip chat and
fetches it from the server. Trip access was added to it earlier; this is the
rest of what that route needed.

The SSRF guard had a hole under it. `isAlwaysBlocked` knew `::1` and `0.0.0.0`
but not the IPv6 unspecified address: `::` starts with a colon, so the `0.`
check never saw it, and connecting to it lands on loopback. `http://[::]:9200/`
therefore passed the guard and came back with the target's title and og: tags
scraped out of the body — an exfiltrating read of anything listening locally,
from any account. `[::0]`, `[0:0:0:0:0:0:0:0]` and `[::ffff:0:0]` are the same
address in other spellings and behaved the same way. The two `::ffff:`-prefix
regexes had the same shape of gap: they matched the dotted spelling of loopback
and link-local, so `::ffff:7f00:1` walked past them. Both checks now decide on
the expanded hextets, which covers every spelling at once, and fe80::/10 is
read as ten bits rather than as the four characters 'fe80'. This is in
ssrfGuard, so every caller gets it, not just collab.

The scrape could be turned into a denial of service. Each og: regex had two
unbounded `[^>]*` runs separated by a literal, so a document with no `>` in it
made the engine rescan the remainder from every `<meta` it passed — quadratic,
and the byte cap admits half a megabyte. Measured on this build: 240KB of
'<meta ' took 58 seconds, and Node has one thread, so that is the entire
server, health check included, on one request. Bounding the runs at 512
characters makes it linear (540KB now takes 1.4s); no real attribute list comes
close to that.

The five-second timeout only covered the headers — `clearTimeout` ran before
the body was read — so a server that answered fast and then dripped one byte
every few seconds held the handler, the socket and the per-request undici agent
open indefinitely. The byte cap counts bytes and at that rate would never reach
one. `AbortSignal.timeout` covers both halves, the agent is closed in a
`finally`, and the three bail-outs that used to return without touching the
body now discard it, which is what cappedFetch's helper is there for.

The rest is smaller. Nothing metered the fetches, so an account with a trip of
its own could drive arbitrary outbound requests in a loop; there is a budget of
sixty new fetches a minute per user now, charged per outbound fetch rather than
per request. Previews are cached for ten minutes and simultaneous askers for
one URL share a single fetch, so the client's habit of requesting a preview per
rendered message — up to a hundred at once, with no debounce — costs one fetch
per new link rather than one per render. Refusals answer with one constant
reason instead of the guard's own: it distinguishes "could not resolve" from
"private address", which together map out the server's internal DNS for anyone
willing to guess hostnames. A body whose declared type is not markup is dropped
rather than combed for og: tags, and an og:image that is not an http(s) URL is
not passed to the client, which puts it straight into an <img src>.

Deliberately no `@RequirePermission` on the route: the client asks for a
preview while rendering a message or a note, never while composing one, so it
belongs with the GET siblings. Requiring collab_edit would blank every preview
for members of a trip whose owner narrowed that right while leaving them able
to read the chat, and it would not stop the abuse it looks like it addresses —
the permission check passes for a trip's owner at every level, and anyone can
create a trip.
2026-08-27 12:03:04 +02:00
Maurice 800f97b8ce feat(mobile): swipe the day details to step through days (#2059)
* @
feat(mobile): swipe the day details to step through days

The phone shell hangs the days off a chip rail pinned to the top of the
screen, which on a large phone is a thin target a thumb cannot reach one
handed. The day details fill the rest of the screen and did nothing.

They now take a horizontal swipe: left for the next day, right for the
previous one, with the panel following the finger, rubber banding at the
first and last day and springing back when the swipe falls short. The
chip rail is untouched and still the accessible path; a committed swipe
announces the day it reached through a live region, and the active chip
scrolls itself back into view once the rail overflows.

The gesture never calls preventDefault or stops propagation on a touch
event and sets no touch-action, so the browser keeps sole ownership of
the card scroll and of the header chip strip, and the long press reorder
bridge keeps its own 10px self cancel. In edit mode the swipe also
refuses to claim a press held past the bridge window.
@

* @
fix(mobile): line the map controls up and widen the POI bar

The compass sat on its own under the POI bar, centre top, while the
locate button floated 42px above the dock on the other end of the
screen. Both are one handed controls, so they now share the band just
above the dock: compass bottom left, locate bottom right, off the same
--bottom-nav-h the map already reads, a dock gap lower than before.

That frees the row under the chip rail, so the nearby places bar takes
the full width between the screen margins and its segments end up the
size of everything else the thumb aims at there. Desktop keeps the
floating, content width bar.
@
2026-08-27 12:01:11 +02:00
Maurice 495c5e95ec fix(mobile): drop stale up-next cards and open the plan on the next day (#2057)
The UP NEXT card kept pointing at this morning's first stop hours after it had
started, and at day one of a trip that ended last month. A header promising what
comes next, sitting above something that is over. It now stays away on a day that
has passed and once today's timed plan has run out — a day whose stops carry no
times at all still shows its first stop, there is nothing stale about that. The
timeline card already collapses the reserved space when there is no up-next, so
the layout closes cleanly.

Seeding the plan day also lands on the next day still ahead when today falls into
a gap in the dates, instead of dropping back to day one. A trip that is already
over keeps opening on its first day.

And a day that was selected before the shell mounted now counts as seeded, so
clearing the selection is left alone instead of being re-seeded on the next
render — which is what the guard was there for in the first place.

Based on #2055.
2026-08-27 11:02:09 +02:00
Maurice f61bab3ffd fix(vacay): open on the current year instead of the last one added (#2056)
The year list comes back sorted, so picking its last element meant Vacay always
landed on the highest year — add 2030 to plan ahead and every visit from then on
opened 2030. It now opens the leave-year period today falls into, falls back to
the closest year the plan has when that period has none, and keeps the year the
viewer is on when a live update reloads the list.
2026-08-27 10:55:27 +02:00
jubnl d194d2cf6f chore(workflows): paths target on push 2026-08-26 17:12:12 +02:00
jubnl 1a126fb6fc chore(readme): add star history 2026-08-26 16:36:34 +02:00
Danette10 f605b730d8 fix(journey): improve video thumbnails and playback layout (#2043)
* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* feat: add PhotoGrid and PhotoImg components for journey photo gallery display

* feat: implement JourneyPublicPage to display shared journey data with timeline, map, and gallery views

* feat: create MobileEntryView component for displaying detailed journey entries on mobile devices

* feat: add docker-compose configuration for video testing environment

* feat: add gallery view components with support for local photo/video uploads and external provider integration

* fix: green up the video layout branch

Four things stood between this and a merge.

index.css carried committed merge conflict markers. A format-on-save had run
over the whole file and collapsed `=======` and `>>>>>>>` onto the line that
starts the popup-tip rule, which makes .trek-map-popup .mapboxgl-popup-tip part
of a malformed selector list - so the parser drops the whole rule and the map
popup's tail comes back as the stray notch its comment describes. Nothing in CI
catches that; invalid CSS just disappears. The file is back to the dev version
with only the video block added, 1301 -> 1323 lines instead of 2057, and the
same for the lockfile, which had only picked up "peer": true annotations from a
different npm run.

PhotoImg referenced an onClick it never took as a prop - the click already sits
on the button wrapping it.

PublicPhoto did not declare media_type even though journey-share.service.ts
selects it in both queries, which is what the `as any` casts around it were
working around. Declared once, the way JourneyPhoto does, and the casts are gone.

The single-photo hero had moved to a thumbnail. Right for a video, whose
/original is the video file and leaves an <img> blank - but a photo hero is
deliberately full size, which FE-JRN-PUBWIRE-008 pins. It now picks per media
type.

---------

Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-25 23:42:11 +02:00
jubnl 5b9aaab847 fix(client): mint upload idempotency keys via randomId()
uploadQueue called crypto.randomUUID() bare, which is undefined on
non-secure origins (plain-http localhost and LAN installs) and threw
instead of uploading. Route it through randomId() like the other two
idempotency-key producers so it falls back to getRandomValues, and
update randomId's header comment to name all three producers.
2026-08-25 23:16:18 +02:00
jubnl 0269273d01 fix(budget): use Math.trunc for user colour index, complete settlement fixtures
The `| 0` truncation in colorForUserId/colorFor existed to coerce an
undefined user_id to 0, but the settlement API always returns user_id —
only the two BudgetPanel test fixtures omitted it. Add user_id to the
fixtures, switch both helpers to Math.trunc (with a Number.isFinite
guard in colorForUserId preserving the old undefined->0 behaviour), and
drop nothing else: TravelerPicker only ever receives required numbers.
2026-08-25 23:16:12 +02:00
jubnl f5bfc321d7 chore(sonar): keep only categorical issue suppressions
Instance-specific findings (S2245 singles, S4790, S5332, S2819, S7767,
S4158 sessionManager, S8786, S6825) are resolved server-side as false
positive / accepted / reviewed-safe instead, so future violations of the
same rule in the same file still surface. Narrow the i18n secrets scope
to the locale directories so top-level code files stay fully scanned,
and drop the comment claims the code contradicts.
2026-08-25 23:16:05 +02:00
Maurice 105cb68316 fix(mobile): text fields no longer show a dark box inside their row
iOS paints its own background inside a text control, and under color-scheme:
dark that fill is darker than the card the field sits on. The mobile fields
are deliberately bg-transparent because the row supplies the surface - which
is why the native fill has nothing covering it and shows through as a dark
rectangle, while a desktop field hides it behind its own background.

Closes #2028
2026-08-25 23:02:13 +02:00
Maurice f052d9d26b docs: the storage seed example lives in the wiki now
Admin-Storage pointed at the README's Storage section for the seed file
example and the compose mount line. That section is gone, so the link went
nowhere and the example existed in no copy at all - the wiki page described
storage-config.json without ever showing one. Both are on the page now.

The commented mount in docker-compose.yml pointed at the same removed
section.
2026-08-25 22:54:54 +02:00
jubnl 167e2e9428 chore(readme): remove last part related to storage 2026-08-25 22:51:41 +02:00
Maurice ce584da168 docs: same spacing above every top-level heading in the README
Three headings sat directly on the paragraph above them while every other one
is preceded by a <br />. Sub-headings keep following their prose directly, as
they did before.
2026-08-25 22:49:13 +02:00
Maurice c563159072 docs: point the README at the wiki and the compose file it duplicates
Three more sections that only restated what is maintained elsewhere, and had
started to drift: the embedded compose example still pinned tmpfs at 64m where
docker-compose.yml has said 128m for a while, and it was missing the proxy,
language and kitinerary options the real file documents inline.

The headings stay, so the in-page nav link to #docker-compose-production and
any external link into these anchors still land.
2026-08-25 22:49:13 +02:00
Maurice 8087919d31 Remove storage configuration details from README
Removed detailed storage configuration and provisioning instructions from the README.
2026-08-25 22:49:13 +02:00
Maurice b597d71836 Fix formatting issues in README.md 2026-08-25 22:34:42 +02:00
jubnl 9bbbef6279 chore(workflows) add concurrency with cancel in progress 2026-08-25 22:23:56 +02:00
jubnl d4ecf96b64 chore(readme): add build status 2026-08-25 22:21:30 +02:00
jubnl b58515e47e chore(workflow): test workflow how now concurrency key 2026-08-25 22:15:00 +02:00
Maurice c435033343 refactor: one endpoint-name cleaner for both map renderers
The scan that replaced the parenthetical-stripping regex went into the Leaflet
overlay and its Mapbox GL counterpart as two byte-identical copies, which is
what the duplication gate caught. trackColors.ts next door exists for exactly
this reason - both renderers read a track's colour through one function so it
cannot come out different on one map than the other - so the cleaner follows it.

Behaviour is unchanged. The test brute-forces the new function against the regex
it replaced over all 55,987 names up to six characters of "ab() \t", and pins the
32k-space input that used to take a second.
2026-08-25 22:08:32 +02:00
Maurice a3ad53b3bf fix: close the backtracking patterns SonarCloud's profile turns up
The move to SonarCloud brought a wider rule set with it, and 23 more regexes
that can be made to hang on a crafted near-match. 21 are fixed here, each proven
equivalent to the pattern it replaces by brute force rather than by reading.

Two of them are reachable across users, not just by yourself. A reservation
endpoint name is free text any trip member types, stored and then read back in
every other member's browser while the map draws its labels, and the pattern
over it takes 2.3 seconds on a 32k paste - so one member could freeze the map
for the whole trip. The mobile transport sheet reads the same field. Both now
scan instead of backtrack, and both were matched against the desktop twin the
earlier sweep had already fixed rather than given a third spelling.

The markdown link stripper needed the same treatment as the tag stripper before
it: the label class cannot be disambiguated in the pattern, because it matches
an opening bracket as well, and narrowing it changes what "[a[b](c)" produces.
It is a scan now, with all four of the regex's quirks intact.

Also here: the two encoding warnings the scanner reported. A decorative comment
line in JourneyDetailPage.test.tsx had picked up a replacement character
somewhere along the way, and calendar.service.test.ts asserts on one
deliberately - that one is now written as a U+FFFD escape, so the file no longer
carries the raw bytes while the test says exactly what it said before.

Seven findings were deliberately not changed and are flagged in SonarCloud with
the reasoning. Five are false positives - patterns with no overlapping
quantifier pair, where the only cure would change how nested brackets parse.
Two are marked accepted rather than dismissed, because they are real: the readme
check's heading pattern has a proven linear replacement that cannot land until
the registry's copy of the same script can move with it, and
`export let JWT_SECRET` is what makes an admin's key rotation take effect
in-process instead of silently waiting for a restart.
2026-08-25 22:08:32 +02:00
jubnl b98363b2b0 chore(workflow): single workflow that runs the test AND the sonar gate (#2046) 2026-08-25 21:54:31 +02:00
jubnl 981a634dfb chore(sonar): switch to cloud 2026-08-25 19:42:45 +02:00
Maurice 62b13a278e refactor(common): one shared comparator instead of three inline copies
The coverage ratchet caught something worth keeping: an inline
`(a, b) => (a < b ? -1 : a > b ? 1 : 0)` is a new function with four branches at
every call site, and two of the three in src/nest/common sit on error paths that
a passing test never walks. Three restatements of the default sort order were
enough to pull the domain under its pinned thresholds (functions 97.8% against
99, branches 87.92% against 89).

Writing tests to walk those error paths would have been solving the wrong
problem. byCodeUnit says the same thing once, is covered once, and the callers
read better for it.

Its test asserts equality with a bare `.sort()` rather than hand-written
expectations, since "identical to the default order" is the whole contract. It
also pins what byCodeUnit is NOT: `['Zelt', 'Übernachtung']` keeps that order,
where localeCompare swaps it — so the next person reaching for a comparator on a
list a human reads picks the other one.
2026-08-25 19:26:38 +02:00
Maurice 071f516c0f refactor(llm-parse): use the shared tag stripper instead of a second copy
Two passes over the ReDoS findings arrived at the same scanner independently —
one replacing `/<[^>]+>/g` in the KML importer and the Commons metadata helper,
the other in the mail/HTML text extractor. The algorithm is the same down to the
edge cases, so the extractor now imports common/stripHtmlTags rather than
carrying its own.

Checked before folding them together rather than assumed: the two
implementations were compared over 2,441,406 generated strings and agree on
every one. The shared helper takes the replacement string as a parameter, which
is the only thing that differed — the extractor substitutes a space, the KML
importer nothing.

Two cases the shared helper's own test covers only in the abstract get a
concrete one here, since this is where the input is a file somebody uploaded: an
empty `<>` stays literal, and a `<` inside a tag is content, so
`3 < 4 and <b>5</b>` strips to `3 5` rather than keeping the first bracket. Both
are the old regex's behaviour, which is the point.
2026-08-25 19:26:38 +02:00
Maurice 1ad2e92142 fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.

**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.

`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.

**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.

Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.

sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
2026-08-25 19:26:38 +02:00
Maurice 52d1a0720f fix: say out loud what every sort was already doing
Sonar's S2871 flags a bare .sort(), which orders by UTF-16 code unit rather than
by value. All 30 remaining sites turned out to sort ASCII strings — ISO dates,
currency and country codes, setting keys, route names, OAuth scopes, zip entry
names — where that order is already the intended one. Each says so explicitly
now, with a comparator that reproduces it exactly rather than a localeCompare
that would quietly reorder things.

That mattered more than it sounds. Several of these feed something other than a
display: useAtlas joins the sorted codes into the signature that decides whether
to rebuild the region layer, daysSlice re-pins sorted dates positionally onto
reordered days, and three timeline views pick a day's colour by indexOf into the
sorted array. Switching any of them to a locale collator would have been a silent
regression.

Two smaller ones came with it. The .map(placeBtn) calls in FileManagerAssignModal
pass the index as a second argument, which placeBtn declares and uses for its
React key — correct already, now written out as .map((p, i) => placeBtn(p, i)).

And budgetSlice's optimistic paid-toggle stored the raw boolean behind
`paid as unknown as number`, while the schema declares z.number() and both the
REST response and the WebSocket event carry 0/1. So the optimistic state and the
confirmed state disagreed on type until a refresh. It normalises now, and the two
tests that asserted `true` assert the contract.

Four `| 0` truncations are listed in sonar-project.properties instead of changed.
Two are a djb2 string hash where the 32-bit wrap is the algorithm. The other two
look like plain truncation of a row id, but `| 0` is also coercing undefined to
0 there — the settlement flows the API returns carry no user_id, so that is the
normal path, and Math.trunc returns NaN and crashes the lookup.
2026-08-25 19:26:38 +02:00
jubnl 4ba407aead feat(plugins): install an older version of a plugin (#2035)
Adds a version picker to the plugin admin panel, on desktop and mobile: every
published version with its own server-computed compatibility verdict, so the
client never re-derives semver range logic.

A deliberate non-latest pick sets an update hold on the row, which keeps the
update banner and "Update all" from immediately nagging the rollback away. The
hold releases when an install lands back on the newest compatible version by any
path, or when the admin resumes updates explicitly. An unresolvable registry
never sets one: refusing to pin on missing information beats silently muting
future updates.
2026-08-25 17:30:27 +02:00
cartoone b5c8182410 feat(planner): clicking the selected day header again deselects it
Same effect as the day panel's close button: selection cleared and the
day detail panel closed. Discussed in #2024.
2026-08-25 17:26:09 +02:00
cartoone 1cb67d5e64 feat(planner): scope the planned places filter to the selected day
With a day selected, the 'planned' filter now shows only that day's stops,
matching how the other filters follow the selection; with no day selected
it keeps showing the whole plan. 'unplanned' still uses the whole-trip
assigned set. Discussed in #2024.
2026-08-25 17:26:09 +02:00
Maurice fe5c6f182e fix(ci): restore the peer dep the swc install drops, and close a test race (#2040)
Two separate reasons v4.0.0 (#1719) is red.

The SonarQube workflow killed all 616 client test files with "Cannot find module
'@testing-library/dom'". That package is a peer dependency of
@testing-library/react and not a direct dependency of the client, and the step
that installs @swc/core's Linux binary passes --legacy-peer-deps, which tells npm
not to put peer dependencies back when it re-resolves the tree. In test.yml that
step lives in the server job, which never runs client tests, so folding the jobs
into one (#2039) is what brought the two together. It now installs alongside the
swc binary, with its version read from the peer range rather than pinned, and a
one-line resolve check right after fails fast with the module name instead of six
hundred identical failures twenty minutes later.

MAdminStoragePanel's FE-MOB-MSTOR-015 was a genuine race in the test, not in the
panel. Its POST handler incremented postCount, then awaited request.json(), and
only then pushed to migrationPosts — so between the two there was a window where
one POST had been counted but migrationPosts was still empty. The GET handler
reports `migrations: []` for an empty migrationPosts, i.e. "nothing running", so
a poll landing in that window told the queue it was free to start the second
migration, and the waitFor(postCount === 1) above could resolve inside it too.
Parsing before counting closes it. Verified by running the whole mobile suite at
--maxWorkers=8, where it used to surface.
2026-08-25 17:09:29 +02:00
Maurice 6fe5009c20 ci(sonar): fold the four coverage jobs into one, on the root scripts (#2039)
Review feedback on #2038: the root already has a script that does all of this,
so the five jobs and the artifact shuffling between them were doing by hand what
`npm run test:cov` does on its own. It ends with `--prefix plugin-sdk`, so all
four packages are covered, and each writes its lcov to <pkg>/coverage/ — exactly
where sonar.javascript.lcov.reportPaths looks. 131 lines become 28.

Three things the plain root sequence still needs on a runner, kept as their own
steps so the reason stays visible:
- plugin-sdk is not a root workspace (workspaces = client, server, shared), so
  the root npm ci never reaches it and it needs its own.
- The lockfile was generated on Windows and omits @swc/core's Linux optional
  binary, so the server's SWC transform cannot load. test.yml carries the same
  step for the same reason.
- Only shared is built, not the root `build`. server and client run their suites
  from source through vitest, so building them adds a tsc pass and a full vite
  bundle that nothing here reads.

The cost is wall-clock. The first run of the parallel version took 16 minutes,
with client (11) and server (9) overlapping; sequentially that is closer to 25.
Nobody waits on this job, so the simpler file is the better trade — but it is a
trade, not a free win.
2026-08-25 16:34:08 +02:00
Maurice 616b39a9f0 ci: run the SonarQube analysis from Actions instead of by hand (#2038)
The scan has been a manual step on somebody's machine, which is why the
dashboard could sit on a stale commit without anyone noticing. This runs it on
every push to dev.

Push to dev only, and that is the point: the instance is Community Edition, so
it has no branch or pull-request analysis and the project holds exactly one
view. Analysing a feature branch would overwrite that view with the branch's
numbers until somebody scanned dev again, so the one branch that may publish is
the one everybody reads.

The four coverage jobs mirror the ones in test.yml because Sonar needs the lcov
each package emits — without it every line reports as uncovered and the
dashboard shows a collapse that did not happen. They run in parallel and hand
their reports to the scan job, which needs a full clone: Sonar reads git blame
to date each issue and to decide what counts as new code.

sonar.projectVersion stays pinned here rather than read from package.json.
package.json is still on 3.4.1 while the project has been analysed as 4.0.0 all
along, and since New Code is "previous version", taking the lower number would
silently re-base every new-code metric.

Needs two repository secrets before it can run: SONAR_TOKEN and SONAR_HOST_URL.
2026-08-25 15:51:10 +02:00
Maurice ad2119efd6 docs(wiki): correct what reading each page whole turned up (#2036)
The last pass reviewed diffs, so it never saw a new paragraph contradicting an
older one on the same page, or a long-standing claim nobody had checked.
Plugin authors were told ctx.config carries their per-user secrets and that
definePlugin takes a permissions key — neither is true, and both fail silently.
FORCE_HTTPS was said to need TRUST_PROXY, which it does not.
2026-08-25 15:22:46 +02:00
Maurice 2c2d008dc2 fix: clear every open Sonar security issue, and half the reliability ones (#2037)
* fix(security): mint idempotency keys properly, and resolve the extractor by path

Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.

The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.

The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.

* fix: four tests and sorts that were not doing what they claimed

Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.

budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.

admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.

The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.

Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).

* refactor: give every button a type, and prefer the Number statics

Two mechanical sweeps, no behaviour change intended in either.

A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".

The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
2026-08-25 15:22:38 +02:00
Maurice 7da0080557 docs(wiki): bring the wiki in line with 4.0.0 (#2034)
* docs(wiki): correct the install and operations pages for 4.0.0

Quick Start passed docker an empty ENCRYPTION_KEY: an assignment prefix is not
expanded on the same command line, so the server silently generated its own key
instead of taking the one the page told you to set. The tag examples pinned to
the 3.x line, the documented tmpfs size did not match the shipped compose file,
and several .env values were described as reaching the container when compose
never interpolates them.

* docs(wiki): match the MCP reference to the registered surface

The scope table was missing the whole Collections group and the totals said 27
scopes across 13 groups where the code registers 29 across 14. The Cloudflare
link on the setup page pointed at an anchor on its own page instead of the
heading on Troubleshooting.

* docs(wiki): correct the plugin and admin pages

Three links to Plugin-Development carried a .md suffix, which does not resolve on
the wiki. The admin pages described tabs, permissions and storage behaviour that
had moved on.

* docs(wiki): bring the feature pages back in line with the app

Costs still documented the old panel, where categories could be added, renamed,
reordered and deleted; they are fixed now and the panel is a day-grouped ledger
with settlements. Trip sharing, journeys, maps, packing, reservations and the
account pages had all drifted in the same way.

* docs(wiki): cover the pages the first pass did not reach

The MCP catalog listed 168 of the 201 registered tools; the settlement tools and
rate_place were missing here and the Collections family from the addon page.
Several resource rows claimed a trips:* scope where the handler asks for
trips:read.

* docs(wiki): tighten the corrections a review picked apart

The audit-log resource is the row id, not the credential id; an admin password
reset does not bump password_version the way the page implied; and a few of the
new paragraphs described behaviour more confidently than the code supports.

* docs(wiki): fix what a last read-through caught

Costs shows every figure in your display currency, not the trip's, and an
expense with no payer still counts against the members it is split between.
Studio's cutoff is a 1024px viewport rather than phones alone, and the
Integrations tab is gated on the providers an admin has configured.

* docs(wiki): the last few the review caught

A passkey satisfies the MFA policy everywhere except the WebSocket handshake,
which still asks for TOTP, so a passkey-only account under that policy gets no
live updates. Mobile optimizes a day from two places, not three. The ctx.db row
carried an unescaped pipe that split the table, and the Backups table linked
with a wiki link whose pipe did the same.
2026-08-25 12:36:52 +02:00
Maurice dc74d59b11 fix(plugins): stop three ways a hostile archive or child could stall the host
All three sit in code whose whole job is to survive a plugin that misbehaves.

readTarGz trusted the tar size field, which parseInt reads with a leading minus.
At -512 the cursor advance cancels out and below it goes backwards, so the same
header is read forever while every pass appends a member — a 70-byte upload pins
the main thread and exhausts the heap, synchronously, taking HTTP, the socket
and the database with it. The entry budgets never get a say: they run on the
list readTarGz has to finish building first. Sideload is the reachable path.

The supervisor handed onMessage's promise to an EventEmitter listener, which
discards it. A rejection there is an unhandledRejection, the host has no net for
it, and Node ends the process — the supervisor killed by the plugin it contains.

dispatch() read `'_inv' in req.params` before handle()'s try/catch, so params
that are not an object threw a TypeError into exactly that discarded promise.
2026-08-25 11:41:07 +02:00
Maurice bc59289f73 fix(plugins): keep a plugin id from pointing outside its own directory
`POST /api/admin/plugins/:id/uninstall` walks its route parameter into
pluginCodeDir() and hands the result to a recursive remove. Express decodes
`%2F` after routing, so `..%2F..%2Fuploads` matches the route and reaches the
helper as `../../uploads` — path.join then lands in the uploads tree, and
`deleteData` gets the data root the same way. Verified against the express
version in the lockfile.

pluginCodeDir and pluginDataDir now reject an id that could leave its parent.
Narrower than the manifest's ID_RE on purpose: that rule wants 3-40 lowercase
characters and belongs at install time, while this one only has to make the id
unable to steer a path, so it stays permissive about length and case. Every
other filesystem sink in the backend already has its guard — storage keys,
extraction, the wiki, backup filenames. This one did not.
2026-08-25 11:41:07 +02:00
Maurice 40957e909b refactor: drop two returns that could only ever say one thing
Both functions had two exits handing back the identical value, which reads as a
decision and is none — Sonar's S3516, and the last two Blockers on the backend.

colorizeImportedTracks returned the very object it was given, coloured in place.
Saying `void` and letting the two importers return their own result is what the
function was doing all along. PLACES-SVC-005 checked the pass-through and now
checks the thing worth checking: an empty import writes no colours.

acceptInvite bailed out early on a refusal only to return the same result the
line below returns. The notification is what the early exit was really skipping,
so it is the notification that gets the condition.
2026-08-25 11:02:39 +02:00
Maurice fb76074e7c feat(mailer): say so when SMTP certificate verification is off
SMTP_SKIP_TLS_VERIFY (and the matching admin setting) turns off certificate
checking for outgoing mail. That escape hatch stays — an install behind an
internal relay with a self-signed certificate depends on it — but until now it
downgraded the connection without a word, and a transport is built per send, so
whoever flipped it had nothing to go on afterwards.

It now warns once per process, naming the host it applies to. Once, not per
mail: a line on every notification is a line nobody reads.
2026-08-25 11:02:39 +02:00
Maurice 23196aa038 refactor(demo): keep the demo password in one place
It was written out twice — once in the seeder that creates the account, once
in the config payload the login screen reads it from. The two have to match
exactly or the published demo credentials stop working, which is a poor thing
to leave to copy-paste. It moves next to DEMO_EMAIL_PRIMARY, which the same
comment block already argues for.

Also clears a Sonar S2068 "hard-coded password" on the auth service: the value
is public on purpose, but a literal next to a `demo_password` key is exactly
what the rule looks for.
2026-08-25 11:02:39 +02:00
Maurice e3c524dd02 test: give every test case an assertion of its own
SonarQube's S2699 flagged 196 test cases as assertion-less. Almost all of
them end on a bare `await screen.findByText(...)`, which does throw when the
text never shows up — the test is real, the analyser just cannot see it.
Spelling the check out keeps the behaviour identical and makes it readable.

Writing them out turned three tests red, each for a good reason:

- FE-COMP-NOTES-008 read the heading out of the loading panel, which renders
  its own copy and swaps the whole node out once the fetch settles. It now
  waits for the loaded state.
- FE-COMP-PHOTOPROVIDERS-014 matched /connected/i, which also matches the
  "Not connected" the badge starts as — the test passed whether or not the
  button did anything. Exact text now.
- FE-FILES-002 compared an empty list against the empty list it already was.
  It seeds a file first, so the 500 has something to leave alone.

JOURNEY-SVC-066 asserted nothing at all behind a "should not throw" comment;
it now says that out loud and checks no journey entry is left orphaned.
2026-08-25 11:02:39 +02:00
jubnl decc0e1a12 fix(plugins): boot enabled plugins after the rpc-registry scan, not before
PluginRuntimeService booted enabled plugins from onModuleInit, but Nest fires
same-module onModuleInit hooks in providers-array order, where the runtime
precedes PluginRpcRegistryService — so every boot-activated plugin's rpc host
was bound from a still-empty registry and its first RPC (typically db.migrate
in onLoad) came back PERMISSION_DENIED on every cold boot, until an admin
deactivated and reactivated it by hand. Move the boot reconcile to
onApplicationBootstrap, which is guaranteed to run after every module's
onModuleInit, registry scan included.

Also fix the denial message, which interpolated the plugin id where the
permission belonged ('db.migrate requires a permission "featured-guides" was
not granted'); it now names both the missing permission and the plugin.
2026-08-25 08:06:50 +02:00
jubnl 4548743874 chore(packages): add test coverage support to plugin sdk and shared package 2026-08-25 01:22:20 +02:00
jubnl 190dc482e4 fix(sonar): use resourceKey in the i18n issue-ignore entries 2026-08-25 00:49:12 +02:00
jubnl efcdbd94fb fix(client): compute "today" from the local calendar, not UTC 2026-08-25 00:44:42 +02:00
jubnl 67b71a52d1 chore(sonar): stop secret detectors false-positiving on locale files 2026-08-25 00:11:58 +02:00
jubnl bd33d0b1cf chore(sonar): scope shared specs as tests and drop a stale exclusion 2026-08-25 00:06:50 +02:00
jubnl d110b3bb6d Fix/plugin audit findings (#2031)
* fix(plugins): enforce manifest apiVersion at install and activation

* test(plugins): exercise the absent-apiVersion default

* feat(plugins): generate the core-event catalog into host-facts

* docs(plugins): explain the packing.listBags write-permission binding

* test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header

* fix(plugin-sdk): validate settings options and oauth like the host

* fix(plugin-sdk): model the full manifest shape and stop dropping fields

* fix(plugin-sdk): drop non-string routeProfiles icons like the host

* docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId

* feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host

* feat(plugin-sdk): expose the generated core-event catalog

* feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0

* feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins

* chore(plugin-sdk): fix stale mirror docs and the colliding permissions export

* docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing

* feat(plugin-sdk): raise the scaffold trek floor to 4.0.0

* docs(wiki): bring the plugin pages in line with the current runtime

* docs: bring the workspace CLAUDE.md guides up to date and make them committable

* feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates

* docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing

* chore(plugin-sdk): 1.6.0
2026-08-24 23:48:36 +02:00
Maurice 4bac447ac8 fix(planner): draw only the selected day's automated transports
Closes #2019

The route toggle belongs to one day; the reservation list it filtered is the
whole trip. So turning on a day's routing let every transit reservation in the
trip draw its geometry at once, including the ones belonging to other days —
on a day that has no transport of its own, which is what the reporter saw.

Only automated transports rode it, and that is the distinction they drew: a
manually entered train or flight is type 'train'/'flight' and reaches the map
only through its own per-booking toggle. Only the transit search stores
type 'transit'.

Gate the transit branch on the day, and gate only that one — the per-booking
toggle stays trip-wide, because switching a booking's route on is a deliberate
act about that booking. The predicate is the day-order range, not the departure
day: an overnight journey carries end_day_id and has to stay on both of its
days, the same rule the sidebar timeline already applies. Day order rather than
day id, since ids stop being monotonic once days are reordered.

The day context is optional throughout, so a map without one — collections —
keeps behaving exactly as it did. Not a 4.0.0 regression: it reproduces on
3.4.1 too.
2026-08-23 23:43:14 +02:00
Maurice 6779cd2dd1 fix(studio): fit an outline map to the country the trip was in
Closes #2021

A country is one path but not one landmass. France's is five rings, three of
them Kerguelen, French Guiana and New Caledonia, so fitting a map to its
bounding box asked for 221 degrees of longitude and printed a Paris-Lyon-Nice
route as a single dot. Britain does it through South Georgia, Norway through
Svalbard, Ecuador through the Galapagos — which is why a Netherlands journey
looks perfect and a France one does not.

Satellite only escaped by accident: imagery leaves the shape list empty, so the
fit fell through to the stops. Same control, silently doing nothing.

Fit to the rings the journey is actually near, and keep the whole shape as the
fallback for a map with no route yet. The margin is the route's own size with a
two-degree floor, which is the judgement in this fix: at two degrees a
Paris-Nice route still keeps Corsica, which is the picture that trip wants,
while Kerguelen stays out.

The outlines themselves are untouched — the overseas rings are still drawn,
they just run off the frame the way a coastline does.
2026-08-23 23:43:14 +02:00
Maurice 477e44ef50 fix(journey): keep the mobile timeline at the bottom of the screen
Closes #2022

The story preview carried both `block` and `line-clamp-2`. Tailwind emits the
display plugin after line-clamp, same specificity, so `display:block` won and
`-webkit-line-clamp` — which only does anything on a -webkit-box — was inert.
The whole entry text rendered, the card grew with it, and the timeline it sits
in is bottom-anchored with no height of its own, so it climbed up the screen.
Measured at 430x932: three words put its top edge at y=744, four hundred words
at y=24, two thousand at y=-2916. That is the reporter's "at the top in one
journey, in the middle in another" — it is not the install, it is the longest
comment in the journey being opened.

The same growth explains the other half. The timeline sits above the map and is
a horizontal scroller, so once it covers the screen it takes every drag Leaflet
would have got, and only the gap between it and the dock still reaches the map.

Dropping the display utility fixes today's cards; capping the card height means
no future one can do this again. 140px is what the older journey timeline pins
its active card to, so the two look like the same product.

Also lift the map attribution over the dock, which the reporter's screenshots
show sitting on top of it. Both engines, since a phone meets either.
2026-08-23 23:43:14 +02:00
Maurice 4eee08e45c test(middleware): cover the branches the forced-HTTPS change added
The redirect grew three paths nothing exercised: a malformed APP_URL falling
back to the request host, a request the proxy already terminated TLS for, and
the health probe, which talks plain HTTP on the loopback and must not be sent
to a hostname it cannot resolve. The per-domain coverage ratchet caught the
gap - src/middleware sat at 93.44% against its 94% pin.
2026-08-23 11:59:12 +02:00
Maurice 44ddfa510e fix(client): only give a scheme to something that looks like a host
safeExternalHref prefixed https:// to anything without a scheme, which turns
a note that reads "call the hotel" into a link to a host that does not exist.
A dot and no whitespace is what separates www.hotel.com from free text.
2026-08-23 11:59:12 +02:00
Maurice 7c6a17c020 fix(client): make the new rollbacks roll back only what they wrote
The rollbacks this branch added were snapshot-shaped: capture the whole
collection before the write, restore it on any failure. That is wrong in two
ways at once. It undoes work the server already accepted when a later step is
the one that fails, and it throws away whatever a collaborator's socket event
applied while the requests were in flight.

The day-plan reorder is the clearest case: five awaits, and a failing day-note
write reverted transport positions the server had already stored. It now
records only the fields it is about to overwrite, per booking, and drops each
one as its request lands - so a note failure reaches the catch with nothing to
undo, and a booking that arrived mid-write survives.

The notification toggle had the same shape underneath: the rollback was made
surgical but the request still shipped the whole matrix built from the
pre-toggle render, so two overlapping toggles overwrote each other anyway. It
sends the one cell that changed. The test pinned the disagreement as correct;
it asserts the payload now.

Photo reorder awaits allSettled rather than all, so the UI is not snapped back
while the remaining writes are still landing - a partial run really does leave
the server holding part of the new order, and only a total refusal justifies
restoring the old one. Both the desktop editor and the mobile sheet, which was
still swallowing every rejection.

Also: the geolocation start guard is a run generation now, because a start
issued after a stop re-sets a plain flag while the first run is still parked in
the iOS permission prompt; a category rename reports once for the batch and
rethrows, so the settings dialog stops closing as if it had worked; and a 404'd
import job stops telling the user their file was invalid when it had simply
expired.

The clipboard fallback for plain-HTTP installs was pasted into three files by
this branch. It is one helper now, and the two copy buttons in the members
modal that still called navigator.clipboard unguarded use it too. Same for the
fourth local-date helper written next to the three that already import the
existing one.
2026-08-23 11:59:12 +02:00
Maurice 818021908f fix(client): correct the second pass over this branch's own client fixes
Four of the fixes already on this branch were wrong, and an adversarial read
of the diff is what turned them up.

The shared merge helper claimed to implement the server's
COALESCE(assignment_time, place_time) and did not: it pinned the embedded
times to whatever the assignment already showed, ignoring the override
column entirely. Sharing that with the WS applier traded one bug for
another - a collaborator moving a place from 08:00 to 10:00 stopped reaching
the day card for every assignment WITHOUT an override, which is most of them.
It merges the way the server projects now, and the test that was supposed to
pin this seeded a fixture the server could never produce (embedded times that
differ from the pool row with no override to explain the difference).

The per-iteration auth gate went into every loop in tripSyncManager except
the one that matters most: the mutation-queue replay, which writes server
responses straight into Dexie. After a logout that is the shared anonymous
database. The fire-and-forget tags/categories writes two lines below a gate
added in the same commit were ungated for the same reason - they resolve
later, and the check has to happen then.

safeHttpUrl is backed by placeWebsiteSchema: http(s) only, capped at 500
characters. A booking url is deliberately free-form - shared's own contract
says so in as many words, because people paste bare hosts and long provider
deep links - so guarding it with the stricter schema turned a link somebody
saved last year into dead text. safeExternalHref refuses the schemes that
execute in this origin and gives a scheme-less value https://, which is what
an address bar does with the same input.

And the memories timeout was 20s while the server's own budget for a Synology
call is 30s, doubled again when the session has expired and it re-authenticates:
a slow NAS that answered before this branch would have started reading as
disconnected. Those calls had no client timeout at all before; they have none
again, and the ones that need to give up early carry an AbortSignal.

The notes thumbnail also loses its NUL byte cache separator - it made git treat
the whole file as binary, so a security fix landed in it unreviewable - and
gains the same cache cap its chat sibling got.
2026-08-23 11:59:12 +02:00
Maurice 502a3fd4e8 fix(shared): express the URL scheme check without a control-character regex
Same behaviour, and the root lint job stops failing on no-control-regex. The
stripping is the point and has to stay: browsers drop everything at or below
U+0020 before they resolve a scheme, so a tab spliced into 'javascript:' is
still a javascript: URL to them.
2026-08-23 11:59:12 +02:00
Maurice 38f3cb3101 fix(server): the second pass over this branch's own fixes
An adversarial review of what had already landed here turned up three that
were wrong, and they are the reason for this commit.

The CSP change removed 'unsafe-eval', which is not leftover: heic-to's
libheif build initialises embind through new Function(), and without it every
iPhone .heic upload fails in the browser. 'wasm-unsafe-eval' alone was tried
once already and was not enough (93b51a0b). Put back, with the reason written
down this time and the test pinning it rather than its absence.

The idempotency in-flight guard never fired. finalize() runs when the
handler's observable completes, but Nest writes the response - and therefore
stores the row - several microtasks later, so the waiting request always woke
up before the row existed and ran the handler a second time. It releases from
the point the row is written now; finalize is the backstop for handlers that
answer another way. The test drove the two in the opposite order from Nest,
which is why it passed either way.

And the trip-scoping check treated an id that resolves to nothing like one
belonging to someone else. accommodation_id has no foreign key, so shortening
a trip's dates leaves a booking pointing at a gap - which update() has always
healed by clearing the link, and which would have become a permanent 400.

The rest: a reply to a soft-deleted chat message is refused and the create
path blanks the quote like the list path does; the ws-token buckets are keyed
by account rather than by IP, where every user behind one NAT shared a
ceiling; the weather and vacay caps read through the stream, since a chunked
response has no content-length to check; the Google host gate takes country
domains again; abandoned response bodies are cancelled instead of left to GC;
the boot failure prints its errno; and a share link that is not yours to read
now says so instead of reporting that the journey has none.

TICKETJSON gets a real repair: the in-place GLOB fix only helps installs that
have not run it yet, so an appended migration puts back the note text the
case-insensitive LIKE cut off, for rows where what it stored is not valid
JSON. A genuine receipt always parses, so it cannot be touched.

Wiki: the audit log records the IP Express resolves after TRUST_PROXY, not
the leftmost X-Forwarded-For entry a caller can write. That is the right way
round, but it means the hop count has to be accurate - said plainly in both
places now.
2026-08-23 11:59:12 +02:00
Maurice 3e0bfbbb29 fix(client): give the desktop halves the same fixes their mobile twins got
Several of the fixes in this branch landed on a mobile screen while the
desktop component with the identical bug sat in another package and was left
alone, which leaves the two disagreeing about the same handler.

So: the appearance tab flushes its debounced write on unmount, the journey
share copy guards navigator.clipboard (undefined on a plain-HTTP self-hosted
install), the admin collab toggle rolls back from a scoped snapshot instead
of a stale one, the reservation modal parses metadata through the guarded
helper rather than a bare JSON.parse that takes the route down, and the
notification toggle surfaces its failures and stops racing concurrent
toggles.

The Journey components reached the memories provider-status endpoint with a
raw fetch(), past every apiClient interceptor; they go through a small
memoriesApi now. And the server half of the OSM menu_url finding: the client
renders it through safeHttpUrl, so place-enrichment should not be handing a
javascript: value out in the first place.

The share section hides itself when the server says the link is not this
user's to manage, rather than offering a create button that 403s.
2026-08-23 11:59:12 +02:00
Maurice e72338bbbc i18n: the English left in Costs, Studio and the mobile timeline
Three surfaces still had English written straight into JSX in a codebase that
gates on key parity: the reworked expense modal, the Studio quickbar and
rotation handles, and the leg-mode menu on the mobile day plan. Keyed, and
translated into all 23 locales rather than pasted.

Also reworded the plugin geolocation permission: it told the user their
browser would ask, and it never does - TREK answers the plugin from the
page's own location permission.
2026-08-23 11:59:12 +02:00
Maurice 27be764164 test(client): the coverage that goes with the previous commits
Left out of the commits they belong to by hand, not by intent: the mobile
screen suites, the tripRepo cache-first read behind the startup-destination
fix, and the MSW handlers the stricter unhandled-request setting needs.
2026-08-23 11:59:12 +02:00
Maurice c75e958fc0 fix(client,sdk): the smaller silent failures, and a dead SDK test file
The SDK's parity suite had an unescaped apostrophe in an it() title, so the
file did not parse and vitest reported it as "no tests". Both guards in it
were dead - including the byte-identical egress-policy check the package's own
notes call the worst possible bug in that module to lose. No CI job runs the
SDK suite, so it landed quietly; the next npm publish would have failed on it,
since prepublishOnly runs the tests.

Also in the SDK: the dev preview answered trek:geolocation without checking
the geolocation:read grant, so a plugin whose manifest is missing the
permission works perfectly in dev and is dead once installed. That is the
lenient direction, which is the one that ships broken plugins.

Client side: the background-task poll never dismissed a 404'd import job and
spun forever, "Update All" collapsed its busy flag after the first plugin
settled, the lightbox had no cancellation so a slow signed URL rendered the
wrong image, the plugin geolocation watch was torn down by any unrelated
settings write, manual date entry accepted 31 February, cover previews were
never revoked, and check-all issued one request per packing item.
2026-08-23 11:59:12 +02:00
Maurice b3d8bf08f1 fix(mobile): local dates, revoked object URLs, and writes that said nothing
Four screens derived "today" from toISOString(), which is the UTC calendar
day: an expense filed at 17:00 in Los Angeles was dated tomorrow, a to-do went
overdue eight hours early in Tokyo, and a new journal entry got the wrong day
either side of midnight. All four take the viewer's own calendar day now,
through the helper the planner already had.

Three sheets built preview object URLs in render and never revoked them, so
scrolling a picker leaked one blob per pass. Photo reorder fired its PATCHes
from inside a setState updater, which StrictMode double-invokes.

And several writes failed in silence: the notification toggle, the collab
feature switch rolling back to a stale snapshot, the appearance change whose
debounced write was dropped on unmount so it reverted on the next reload. The
backup-code print page skipped the escaping its desktop twin does, the
clipboard copy threw on a non-HTTPS install, and reservation metadata was
JSON.parsed unguarded in render, which takes the whole route down.
2026-08-23 11:59:12 +02:00
Maurice 19745b7b0d fix(studio): keep "Keep mine" from throwing the document away
After a save conflict, Keep mine set the version and unblocked writes but
never re-queued the local document, and write() had already cleared the
queue before the request. So if the user picked Keep mine and then simply
closed the editor without typing anything else, saveNow() found nothing
pending and did nothing: their afternoon of edits existed only in memory,
the indicator said idle, and the server kept the other version. The exact
outcome the conflict dialog promises to prevent.

Ctrl+Z was intercepted while typing in a text field too. Studio commits text
on blur, so mid-edit the typing is not in the undo history yet: the browser's
own text undo was suppressed and a previous layout change was undone
underneath the open editor instead. Reads as random data loss.

Also: delete no longer removes locked elements, the auto-layout no longer
freezes an empty GPS path when tracks resolve after the one-shot build, the
back-cover tally cannot print "0 places", the map fetch aborts when Studio
closes, and a double Enter in the list-name field stops creating two lists.

The page-pattern gate had quietly stopped covering anything: the Desktop-shim
refactor moved every real container out of the default export the checker
reads, and its regex missed useRef<T>(null) anyway. Widened on both counts,
and the state it immediately found moved into the hooks.
2026-08-23 11:59:12 +02:00
Maurice 7959470802 fix(collab): scope the realtime handlers to the trip, and stop losing writes quietly
The notes and polls WebSocket handlers applied every event they saw without
checking which trip it belonged to, so an event for another trip the user is
a member of landed in the open one. The poll load effect had no cancellation
either, so switching trips fast let the old response win.

What's Next compared trip-local calendar dates against a UTC one while
building the time from the local clock. West of UTC that means the rest of
today disappears from the widget every evening and tomorrow's items are
labelled Today; east of UTC it happens after local midnight instead.

A note website was rendered as a raw href with no scheme check, the same
stored-javascript: shape the planner had. Category rename walked the notes in
a loop with no handling for a partial failure. And the school-holiday region
labels resolved German first for every locale.
2026-08-23 11:59:12 +02:00
Maurice 61d4fd4d64 fix(planner): scheme-check the booking URL, and stop the serial routing calls
A booking's url is rendered as a plain href to every other member of the trip
and nothing ever checked its scheme, so a collaborator could store
javascript: there. The place website next to it has been going through
safeHttpUrl for exactly this reason, with a comment saying stored values
predate the server check - this is the surface that was left. Both ends now:
safeHttpUrl at the render site, and the scheme in the shared contract so
nothing new gets stored, additively so existing rows still load.

The per-leg routing rewrite turned one multi-waypoint request per run into
one serial request per leg. A twelve-stop day is eleven round trips to a
public OSRM in sequence before a single connector appears, and toggling km/mi
does it all again. Group consecutive legs that resolve to the same mode into
one request and fire the groups concurrently, keeping leg order on the join.

Transport reordering wrote to the store and persisted with a bare catch, so
a failed PUT left the sidebar showing an order the server never got; it
reconciles now. Plus the smaller ones: the OSM menu_url rendered unchecked,
expand/collapse-all persisting to a different storage than the day list reads,
the debounced address search with no ordering guard, the accommodation save
chain with no error path, and window.__trek_map holding a destroyed map.
2026-08-23 11:59:12 +02:00
Maurice 47a4276e84 fix(client): make the offline core survive a logout and stop clobbering itself
logout() unregistered every sync trigger, and the only place that registers
them is App's mount effect - which never runs again, because logout navigates
through the router rather than reloading. Log out, log back in, go offline,
edit something: it queues and nothing ever flushes it. No online listener, no
visibilitychange, no 30s heartbeat, no WS reconnect hook. Register on auth
success instead; registerSyncTriggers is already idempotent.

The background sync loops only checked the auth gate on entry. Logout swaps
the Dexie handle to the shared anonymous database, so a trip loop or a blob
download still in flight afterwards wrote the logged-out account's itinerary
and documents into it - the database that survives logout and greets the next
person on a shared device. Check the gate per iteration.

And the remote place:updated applier replaced the whole embedded place, while
the local reducer right next to it preserves the assignment's own times. Same
event, two answers, and the remote one wipes a scheduled 09:00-10:30 off the
itinerary when someone else renames the place. One merge helper now, used by
both, so they cannot drift again. The test that should have caught it seeded
null times and asserted only the name.

Also: moving a day note deleted before it created, and restored the note only
locally when the create failed; t() interpolation was corrupted by a '$' in a
parameter value; a 429 upload was treated as permanently failed; and the
geolocation watch was started inside a state updater.
2026-08-23 11:59:12 +02:00
Maurice eadb3703bc fix(shared,tests): bound the book contract and close the gaps that hid all this
Book page setup and element geometry were unbounded numbers, so a shared
book could be saved with zero, negative or astronomical dimensions and every
consumer downstream - the canvas, the PDF export - had to cope. Day-note caps
had drifted from the limits their own doc comment states, and icon had none.

On the test side: the path filter meant a PR touching only the root lockfile
ran no jobs at all, which is exactly the change class most likely to break a
native binding; the canAccessTrip mock is hand-copied into 72 files and had
already drifted from the real query; the path-traversal test asserted only
status !== 200, so a 500 from a broken guard passed it; and one unit test
shelled out to a real kitinerary binary if the host happened to have one.
2026-08-23 11:59:12 +02:00
Maurice a7aaa29c16 fix(db): stop losing secrets and notes to the boot and migration paths
An unreadable or empty encryption-key file was treated the same as a missing
one: regenerate and overwrite. That is the one case where regenerating is
the worst possible move - every secret already at rest becomes undecryptable,
permanently. Both now refuse to start and say which file to fix.

The key-rotation script advertised re-encrypting all stored secrets while its
hardcoded column lists had drifted: the AirTrail key, the per-user LLM key
and the plugin OAuth tokens were never migrated, so they silently stopped
decrypting after a rotation. The parity test now pins the full set against
what the code actually encrypts, rather than two tables of it.

The schema_version bump ran outside the migration's transaction, so a crash
in between left the schema advanced and the version stale - the next boot
replayed a non-idempotent step and exited, turning one crash into a boot
loop. And the TICKETJSON receipt migration matched with LIKE, which SQLite
treats case-insensitively, so a note somebody had typed starting 'ticketjson:'
was cut up and its text dropped.

Also: APP_VERSION was interpolated into migration SQL instead of bound, the
capture-metadata backfill passed (photoId, userId) to a (userId, photoId)
signature and its mock keyed on the wrong argument, TRUST_PROXY=0 was
accepted and silently coerced to 1, demo-reset hardcoded the db path instead
of honouring TREK_DB_FILE, and a demo instance with no DEMO_ADMIN_PASS set
now says out loud that its admin password is the published default.
2026-08-23 11:59:12 +02:00
Maurice 8e5fdaa877 fix(server): tighten the guard, audit and token paths
Permissions cached an empty map before it had loaded, so a DB error during
the load cached permissive defaults and every later check answered from that
cache - fail-open, and it never expired. The rate limiter grew its per-IP map
forever; the GC the legacy version had was dropped in the fold. Idempotency
did a check-then-insert, so two concurrent duplicates both ran the handler.

Audit rows recorded whatever X-Forwarded-For said, which the client writes,
and the request-body debug logger redacted by exact key match - it missed
refresh tokens, api keys and PKCE verifiers. FORCE_HTTPS built its redirect
from the client's Host header. CSP asked for unsafe-eval on top of the
wasm-unsafe-eval it actually needs.

The OAuth deny path redirected to an unvalidated URI, ws-token minting had
no rate limit at all, and PKCE was skipped when code_verifier was absent
rather than refused. An MCP session resumed with a token whose scopes had
since been reduced kept the capability set it was created with, and the
session cap was checked before the async registration that increments it.

Plugin frame containment was lexical, so a symlink out of the directory was
never noticed, and instance config accepted keys the manifest never
declared.
2026-08-23 11:59:12 +02:00
Maurice 4bfb277423 fix(journey,files): close the cross-trip and role holes on the sharing surface
Files: two :id handlers took the file id from the URL and never resolved it
against :tripId. Membership of any trip was enough to read another trip's
file links (with the joined reservation titles) or to delete one. Both now
resolve the file the way every sibling handler already does.

Journey: a contributor with role 'viewer' - blocked from every other write
in the domain - could overwrite or delete the Studio book, and could read
the share token, which is an anonymous credential that outlives their
contributor status. The book writes take canEdit now and the token read
takes the owner check its own create and delete already use.

Also: the share-link MCP tool reset every flag to permissive defaults when
it was called to refresh a link, so a journey deliberately shared without
its gallery or map quietly started publishing both; setShareLink dropped
newest_first entirely; the public routes ignored the Journey addon switch;
and photo uploads committed to storage before the access check, leaking the
objects on the 403 path.

Trips: DELETE answered 403 to someone with no access at all, which turns
sequential ids into an existence oracle. A member who simply lacks
trip_delete still gets 403.
2026-08-23 11:59:12 +02:00
Maurice 515b832fa3 fix(collab): stop serving the text of deleted chat messages
A deleted message keeps its row so the client can draw the placeholder off
the flag, but listMessages still returned the original text - in the list
itself and in the reply preview joined onto whatever quoted it. Both REST
and the MCP resource read through that one method, so blanking it there
covers every surface.

Also cap the link-preview read, and delete the spooled upload when
addNoteFile refuses the request instead of leaving it on disk.
2026-08-23 11:59:12 +02:00
Maurice 97ebe23d3b fix(server): bound every outbound fetch
The rule is a timeout, a size cap and boundary validation on anything that
leaves the process, and several call sites had only the timeout. A link
preview, a maps URL resolve, a wiki fallback and a holiday lookup all read
the whole body into memory, so a caller who picks the URL picks how much
memory the server spends - a few concurrent multi-hundred-megabyte responses
is the whole heap.

Two of them also fetched more than their name suggests. resolve-url fell
through to a full page GET for any host once it found no coordinates, which
makes the server a general-purpose outbound proxy wearing its own IP. The
weather URLs took lat/lng straight from the caller into the query string.

readCapped/exceedsDeclaredLength collect the pattern transit.service.ts and
the plugin installer had each grown separately, so there is one of it now.
Callers that only scan the head of a document (og: tags, a coordinate regex)
keep the truncated bytes and find fewer fields; callers that must have the
whole thing reject it.
2026-08-23 11:59:12 +02:00
Maurice b163c1e05d fix(server): hold DELETE bodies to the same contract as every other write
The body-contract boot gate only looked at POST/PUT/PATCH, so a DELETE that
reads a body slipped past it. Two do; one was already typed, the other read
photo_id straight off an untyped Record. Give it a contract from shared like
everything else, then add DELETE to the gate so the next one cannot repeat it.
2026-08-23 11:59:12 +02:00
Maurice 3f8a7a1ea9 fix(feeds): keep subscribable calendars working under default-deny
The ICS feed routes are documented as public - the secret token in the URL
is the credential - but nothing in the code said so, and the guard this
release makes global is default-deny. Every calendar client subscribed to
/api/feed/trip/<token>.ics would have started getting 401 on upgrade, and
a calendar app that stops fetching just goes quiet.

The e2e suite asserted anonymous access the whole time. It booted
[DatabaseModule, RealtimeModule, FeedsModule], and the APP_GUARDs live in
AppModule, so it was asserting against an app that had no opinion on
anonymity. It boots the real guard stack now, and fails without the fix.

The boot ratchet could not have caught this either: it only looked at routes
carrying @Public(), and GlobalAuthGuard stands down for ANY declared chain -
so a chain of guards that refuse a caller but never resolve one answers
strangers, invisibly. Split the inventory in two and write that half down as
well. It turns up two routes nobody had listed: the passkey login ceremony,
which genuinely runs before a session exists.
2026-08-23 11:59:12 +02:00
Maurice d91eda690a fix(reservations): keep every id in a booking inside its own trip
reservation_edit on the trip in the URL says the caller may write here. It
says nothing about the ids they put in the body, and the write contracts are
open records, so day_id, place_id, assignment_id and accommodation_id all
arrived unchecked. A booking created in one trip could carry another trip's
accommodation_id, and deleting it ran the cascade against that foreign row.

The MCP tools have refused foreign ids since they were written. Put the same
check where every writer reaches it, and scope the two day_accommodations
statements to the trip so a row stored before this still cannot be followed
out of it.
2026-08-23 11:59:12 +02:00
Maurice 39c8a22352 fix(offline): replay queued writes an interrupted flush left behind
A flush marks each row 'syncing' before it sends, and only ever picks up
rows that are 'pending'. Kill the tab mid-request - the OS evicting a PWA
does exactly this - and the row stays 'syncing' forever: it keeps counting
towards the pending badge, but nothing ever retries it, fails it or shows
it as a conflict. Anything queued behind it that depends on it later fails
with 'unresolved temp id'.

Stamp the row when it goes in flight and reclaim it on the next flush. The
axios instance times out at 8s, so a minute is well past anything that
could still be running, and the mutation id doubles as the idempotency key,
so replaying a write the server already applied is answered from its cache.
2026-08-23 11:59:12 +02:00
jubnl e91ad69c83 fix(atlas): visual empty stats state 2026-08-22 18:04:38 +02:00
Maurice e9090fa33c fix(notices): confine the 4.0.0 release modal to the 4.0.x line
Without an upper bound it would greet somebody who jumped 3.x straight to
4.2 with a summary of a release they skipped, and it would run alongside
the next release's own notice.

maxVersion is exclusive, so 4.1.0 covers every 4.0.x patch. Pinned in the
registry test, together with the handover from the thank-you notice: at no
version may both be active.
2026-08-22 15:48:13 +02:00
Maurice bb7c484ca4 feat(notices): add the 4.0.0 release modal
The thank-you modal was one column of markdown for every notice. A release
needs more room than that: what shipped, and why it took what it took.

Adds a `release` block to the notice contract. A notice carrying it renders
through ReleaseNoticeModal instead of the generic body — the release on the
left, the note from the maintainer on the right, both halves driven entirely
by translation keys so the next release only edits the registry entry.

Sizing lives on one CSS variable (--rn-s), so the panel scales as a piece and
the design's proportions hold at every breakpoint.

The thank-you notice gets maxVersion 4.0.0: from there on the release modal
carries the same thank-you and the same two support links, and installs still
on 3.x keep the old one.

All 23 locales.
2026-08-22 15:48:13 +02:00
jubnl e60427f813 Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)
This reverts commit 94736112cf.
2026-07-25 13:00:55 +02:00
dependabot[bot] 94736112cf chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 12:58:59 +02:00
1735 changed files with 106380 additions and 10315 deletions
+18 -2
View File
@@ -132,7 +132,8 @@ jobs:
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
# Deliberately NOT merged: the per-platform folder names are what lets
# the manifest below be assembled in a fixed order.
- uses: docker/setup-buildx-action@v3
@@ -144,9 +145,24 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
# amd64 first, always. This used to be a glob over hash-named files, so
# the order changed from one release to the next: 3.4.1 listed amd64
# first and 4.0.0 listed arm64 first. Clients that read the first entry
# of the index rather than matching their own platform then compare the
# wrong digest and report "no update" — which is what Unraid users hit
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
digests=()
for platform in linux-amd64 linux-arm64; do
dir="digests-$platform"
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
for f in "$dir"/*; do
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
done
done
docker buildx imagetools create \
-t "mauriceboe/trek:latest-pre" \
-t "mauriceboe/trek:$MAJOR_TAG" \
+18 -2
View File
@@ -176,7 +176,8 @@ jobs:
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
# Deliberately NOT merged: the per-platform folder names are what lets
# the manifest below be assembled in a fixed order.
- uses: docker/setup-buildx-action@v3
@@ -188,9 +189,24 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
# amd64 first, always. This used to be a glob over hash-named files, so
# the order changed from one release to the next: 3.4.1 listed amd64
# first and 4.0.0 listed arm64 first. Clients that read the first entry
# of the index rather than matching their own platform then compare the
# wrong digest and report "no update" — which is what Unraid users hit
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
digests=()
for platform in linux-amd64 linux-arm64; do
dir="digests-$platform"
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
for f in "$dir"/*; do
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
done
done
docker buildx imagetools create \
-t "mauriceboe/trek:latest" \
-t "mauriceboe/trek:$MAJOR_TAG" \
+5
View File
@@ -2,6 +2,11 @@ name: Lint & Prettier
on:
pull_request:
branches: [main, dev]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
+4
View File
@@ -9,6 +9,10 @@ on:
permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
scout:
# Docker Hub secrets are not exposed to pull requests from forks, so the
+296 -77
View File
@@ -1,9 +1,60 @@
name: Tests
name: Tests & SonarQube
permissions:
contents: read
# The repo's test/quality CI and SonarQube Cloud analysis in one workflow, so
# each test suite runs once per event instead of once for the old test.yml and
# once more for Sonar's coverage. Pushes to dev and main produce branch
# analysis; pull requests get PR analysis with decoration on the PR itself
# (the scanner picks the context up from the Actions environment).
#
# Job layout — three tiers:
# - Preflight: plugin-facts, i18n-parity and the quality jobs
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
# of them, so a gate failure stops the run before the expensive coverage
# fan-out spends ~15 runner-minutes on a result the gates already sank.
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
# runs the MinIO contract suite. Coverage jobs run tests only — with the
# gates cleared in preflight, they can only fail because tests failed.
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
# must stop the scan — a missing lcov file would make every line of that
# package report as uncovered, and the dashboard would show a collapse
# that did not happen. (The preflight gates stop it transitively.)
# - Fork PRs don't receive secrets, so the two jobs that need them skip
# themselves: s3-contract (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN).
# Everything else runs on fork PRs so outside contributors still get the
# full test signal.
#
# Automatic Analysis must stay disabled on sonarcloud.io — it is mutually
# exclusive with CI-based analysis, and it cannot import the lcov coverage
# this workflow produces.
# A push or PR that touches none of these paths can't change the tests or the
# analysis, so don't spend the coverage fan-out on it. For a docs-only push
# that means no run and no fresh branch analysis — harmless, since the last
# analysis is still accurate for unchanged code. GitHub Actions doesn't
# support YAML anchors, so the two lists are literal copies: keep them
# identical when editing either one.
on:
push:
branches: [main, dev]
paths:
- 'server/**'
- 'client/**'
- 'shared/**'
- 'plugin-sdk/**'
# npm workspaces hoist every dependency into the single root lockfile,
# so a dependency bump touches none of the four directories above — and
# a native binding like better-sqlite3 is exactly what needs the suite.
- 'package.json'
- 'package-lock.json'
- 'sonar-project.properties'
- '.github/workflows/test.yml'
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
# neither the tests nor the scan — no test reads checked-in markdown
# and SonarCloud has no markdown analyzer. Negations must stay last.
- '!**/*.md'
pull_request:
branches: [main, dev]
paths:
@@ -11,29 +62,51 @@ on:
- 'client/**'
- 'shared/**'
- 'plugin-sdk/**'
# npm workspaces hoist every dependency into the single root lockfile,
# so a dependency bump touches none of the four directories above — and
# a native binding like better-sqlite3 is exactly what needs the suite.
- 'package.json'
- 'package-lock.json'
- 'sonar-project.properties'
- '.github/workflows/test.yml'
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
# neither the tests nor the scan — no test reads checked-in markdown
# and SonarCloud has no markdown analyzer. Negations must stay last.
- '!**/*.md'
workflow_dispatch:
# A newer push to the same branch/PR supersedes the running one — cancel it
# rather than spending the coverage fan-out on a stale commit. Safe here
# because the Sonar scan job runs last and only the latest commit matters.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
plugin-facts:
name: Plugin Permission Facts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
# 22 matches the TREK server runtime (same choice as the plugin-sdk
# jobs and publish-plugin-sdk.yml).
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace server
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy - the SDK's
# generated/host-facts.ts and shared's plugin-permissions.ts - is generated from it
# and checked in, because publish-plugin-sdk builds from a standalone checkout with
# no server present. This fails the PR if a copy drifts, if a new permission has no
# admin.plugins.perm.* translation, or if the CLI has no hint for it.
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
@@ -51,32 +124,9 @@ jobs:
- name: Check i18n key parity
run: node shared/scripts/i18n-parity.mjs --strict
shared-contracts:
name: Shared Contracts (Zod)
server-quality:
name: Server Types & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Typecheck
run: cd shared && npm run typecheck
- name: Run tests
run: cd shared && npm test
server-tests:
name: Server Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -89,18 +139,15 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck shared
# Rides along here rather than in shared-coverage: this job already
# pays the root install and shared build, and a coverage job may only
# fail because tests failed (it gates the scan).
run: cd shared && npm run typecheck
- name: Build server (tsc -> dist)
run: cd server && npm run build
@@ -116,30 +163,73 @@ jobs:
- name: Typecheck tests
# tests/ is outside the build tsconfig, so a service that grows a
# constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at runtime,
# if anything reads it at all. This is the step that catches it.
# compile time: the argument arrives as undefined and throws at
# runtime, if anything reads it at all. This is the step that
# catches it.
run: cd server && npm run typecheck:tests
- name: Lint
run: cd server && npm run lint:check
- name: Run tests
run: cd server && npm run test:coverage
client-quality:
name: Client Types & Lint
runs-on: ubuntu-latest
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
name: backend-coverage
path: server/coverage/
retention-days: 7
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck
run: cd client && npm run typecheck
- name: Lint
run: cd client && npm run lint:check
- name: Page pattern check
run: cd client && npm run lint:pages
plugin-sdk-quality:
name: Plugin SDK Types
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
s3-contract:
name: S3 Driver Contract (MinIO)
runs-on: ubuntu-latest
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty
# and the AIStor container would fail to boot.
if: github.event.pull_request.head.repo.full_name == github.repository
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and
# the AIStor container would fail to boot. Pushes and manual dispatches do
# have secrets, so only fork PRs are filtered out. (The implicit success()
# on this expression also keeps the job skipped when a preflight fails.)
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v6
@@ -203,14 +293,10 @@ jobs:
TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret
run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts
client-quality:
# Split out of client-tests: the suite takes ~11 minutes, and having the
# gates in front of it meant a single lint finding threw away the whole test
# signal for that run. Both jobs pay the install/build, which is cheap next
# to running the two in series.
name: Client Types & Lint
server-coverage:
name: Server Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -221,24 +307,34 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck
run: cd client && npm run typecheck
- name: Run tests with coverage
run: cd server && npm run test:coverage
- name: Lint
run: cd client && npm run lint:check
- name: Upload coverage
uses: actions/upload-artifact@v6
with:
name: sonar-server-coverage
path: server/coverage/
retention-days: 1
- name: Page pattern check
run: cd client && npm run lint:pages
client-tests:
name: Client Tests
client-coverage:
name: Client Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -248,19 +344,142 @@ jobs:
cache: npm
cache-dependency-path: package-lock.json
# Only the shared and client workspaces — a full root npm ci is not
# needed, and the scoped install keeps @testing-library/dom (a peer of
# @testing-library/react) in the tree without the re-resolution dance
# the folded coverage job used to need.
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests
- name: Run tests with coverage
run: cd client && npm run test:coverage
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
with:
name: frontend-coverage
name: sonar-client-coverage
path: client/coverage/
retention-days: 7
retention-days: 1
shared-coverage:
name: Shared Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Run tests with coverage
run: cd shared && npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
with:
name: sonar-shared-coverage
path: shared/coverage/
retention-days: 1
plugin-sdk-coverage:
name: Plugin SDK Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
# 22 matches the TREK server runtime and has node:sqlite, which the
# dev-server tests exercise (same choice as publish-plugin-sdk.yml).
node-version: 22
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
with:
name: sonar-plugin-sdk-coverage
path: plugin-sdk/coverage/
retention-days: 1
scan:
name: Scan
runs-on: ubuntu-latest
# Fork PRs don't receive SONAR_TOKEN — skip the scan rather than fail it.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
# Coverage jobs failing must stop the scan: a missing lcov file would make
# every line of that package report as uncovered, and the dashboard would
# show a collapse that did not happen. The preflight gates already stop it
# transitively through these four, so they aren't repeated here.
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
steps:
- uses: actions/checkout@v6
with:
# Sonar reads git blame to date each issue and to decide what counts
# as new code. A shallow clone leaves it blind to both.
fetch-depth: 0
- name: Download server coverage
uses: actions/download-artifact@v6
with:
name: sonar-server-coverage
path: server/coverage/
- name: Download client coverage
uses: actions/download-artifact@v6
with:
name: sonar-client-coverage
path: client/coverage/
- name: Download shared coverage
uses: actions/download-artifact@v6
with:
name: sonar-shared-coverage
path: shared/coverage/
- name: Download plugin-sdk coverage
uses: actions/download-artifact@v6
with:
name: sonar-plugin-sdk-coverage
path: plugin-sdk/coverage/
- name: Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
# SonarQube Cloud is the scan action's default host, so no
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
# liketrek org.
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
# New Code is configured as "previous version", so this string is what
# draws that line — changing it re-bases every "new code" metric.
# Pinned rather than read from package.json on purpose: package.json is
# still on 3.4.1 while the project has been analysed as 4.0.0 all along,
# and taking the lower number would silently move the baseline. Bump it
# here as part of the release, together with the version bump.
#
# Everything else — sources, tests, exclusions, the documented
# false-positive list — lives in sonar-project.properties so it stays
# reviewable in a PR rather than buried in workflow YAML.
args: -Dsonar.projectVersion=4.0.0
+71 -57
View File
@@ -1,46 +1,41 @@
# 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 four 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.
- **`nest-mcp/`** (`@trek/nest-mcp`) — decorator-driven MCP tool/resource/prompt registration for NestJS, consumed by the server. Deliberately **extraction-clean**: no imports from `@trek/server`/`@trek/shared`, no TREK semantics.
- **`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
Run from the repo root unless noted. Per the user's preference, **use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
```bash
npm run dev # build shared + nest-mcp, then watch shared + server + client concurrently
npm run build # build shared → nest-mcp → server → client (order matters)
npm run lint # lint all four workspaces
npm run format # prettier --write all four
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 (server and shared run eslint --fix and rewrite files; client is check-only)
npm run format # prettier --write all three
```
Tests (the user runs client tests as `cd client && npm run test`):
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, server + client
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:
@@ -50,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 (from the 2026 full-repo audit)
## Project direction
A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` (`00_MASTER_SUMMARY.md` is the entrypoint, `01_STEERING_ROADMAP.md` the plan). Its verdict shapes 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 feature components calling the raw API past the offline core, still ~190 files). 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): ...`). Per user preference, **do not add Co-Authored-By Claude attribution**.
- 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.
+26 -251
View File
@@ -21,10 +21,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
<a href="https://sonarcloud.io/project/overview?id=liketrek_TREK"><img alt="Sonar Quality Gate" src="https://img.shields.io/sonar/quality_gate/liketrek_TREK?server=https%3A%2F%2Fsonarcloud.io&style=for-the-badge" /></a>
&nbsp;
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/liketrek/TREK/test.yml?branch=main&style=for-the-badge">
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
&nbsp;
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
@@ -33,7 +37,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
</div>
---
@@ -238,64 +241,15 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
<h2 id="docker-compose-production">Docker Compose (production)</h2>
<details>
<summary>Full compose example with secure defaults</summary>
```yaml
services:
app:
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- APP_URL=${APP_URL:-} # required for OIDC + email links
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
# - TRUST_PROXY=1
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME=SSO
# - OIDC_ADMIN_CLAIM=groups
# - OIDC_ADMIN_VALUE=app-trek-admins
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
```
Then:
The repository ships a ready-to-use [`docker-compose.yml`](docker-compose.yml)
with secure defaults and every option documented inline. Download it, then:
```bash
docker compose up -d
```
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
See [Install with Docker Compose](https://github.com/liketrek/TREK/wiki/Install-Docker-Compose)
for the full walkthrough.
<br />
@@ -309,6 +263,8 @@ helm install trek trek/trek
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
<br />
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
TREK works as a Progressive Web App — no App Store needed.
@@ -323,36 +279,10 @@ TREK then launches fullscreen with its own icon, just like a native app.
## Updating
**Docker Compose:**
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
```bash
docker compose pull && docker compose up -d
```
**Docker run** — reuse the original volume paths:
```bash
docker pull mauriceboe/trek
docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
> [!IMPORTANT]
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
<h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
<br />
<h2 id="reverse-proxy">Reverse Proxy</h2>
@@ -432,175 +362,18 @@ Caddy handles TLS and WebSockets automatically.
## Environment variables
> [!NOTE]
> Variables are validated at startup (fail-fast). An unset or blank variable
> always falls back to its default, but a variable set to a malformed value
> (e.g. `PORT=abc`, `SESSION_DURATION=bogus`, `DEMO_MODE=maybe`) aborts boot
> with a report listing every offending variable. Boolean switches accept
> `true`/`false`, `1`/`0`, `on`/`off` and `yes`/`no` (any casing).
Every variable, its default and what it does: see
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
<details>
<summary><b>Full reference</b></summary>
## Star History
<br />
| Variable | Description | Default |
|----------|-------------|---------|
| **Core** | | |
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships its wiki and serves it from disk, so Help always matches the version you are running — you should not need to set this. Point it at your own directory to serve custom docs. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release). | bundled `wiki/` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
| **Initial setup** | | |
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `TREK_MANAGED` | Marks the install as centrally administered: the operator owns the configuration, credentials and upgrades, so the instance admin is not offered settings the operator sets. Leave unset when you run TREK yourself. | `false` |
| `PLACES_API_KEY` | Places credential supplied by whoever operates the install. When set, it is used for every lookup and the per-user keys in Admin > Settings are ignored. Leave unset to keep the stored keys. | — |
| `PLACES_API_BASE` | Send the Places calls to this origin instead of `https://places.googleapis.com` (an egress proxy, a cache, a gateway holding the key). Path and query are unchanged, so it has to speak the same API. | — |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user. At the cap, the least-recently-active session is closed to make room | `20` |
</details>
<br />
<h2 id="storage">Storage</h2>
TREK separates *what* it stores (eight content categories — trip documents,
journey photos, cover images, profile pictures, place images, the two photo
caches, and backups) from *where* it stores it (named backends). Out of the
box everything lives on local disk (`uploads/` and `data/backups`). From
**Admin → Storage** you can:
- **Add S3-compatible backends** (AWS S3, Cloudflare R2, Backblaze B2, Garage,
MinIO/AIStor) and assign any category to them.
- **Replicate a backend**: edit it and pick **Mirror targets** — every write is
then also copied to each target. The classic off-site backup setup is:
add an S3 backend, edit `backups-local`, tick the S3 backend as a mirror
target, save. Replica writes happen one after another during each upload, so
a slow or unreachable target slows every upload of every category on that
backend — fine for backups, worth weighing for hot categories. Existing
objects are not replicated when you add a target — press **Sync now** on
the backend to copy them (one sync at a time; progress and cancel in the
panel).
- **Watch the Health strip**: replica failures never fail the original
request; they are recorded and shown there instead. All-clear means every
replicated write landed. Failures also notify admins over the configured
notification channels (first failure immediately, then hourly summaries) —
tune it under Admin → Notifications.
- **Test** any backend from its row (for a replicated backend it probes the
primary and each target individually). Targets must be saved before Test
can probe them.
- **Usage**: per-category and per-backend object counts and sizes, scanned
nightly and on demand.
Backend credentials are encrypted at rest. Without an explicit
`ENCRYPTION_KEY` the implicit key from the data directory is used — that
works, but the implicit key rides inside backups, so setting `ENCRYPTION_KEY`
explicitly is recommended for credentialed backends. On an existing install
that has been running without an explicit key, do not just set a new one —
that would orphan already-stored secrets; use the key-rotation procedure (see
*Rotating the Encryption Key* under Updating) to move to an explicit key.
Notes and limits: reassigning a populated category prompts to move its
existing objects — accepting copies everything to the new backend, flips the
category, then sweeps any writes that raced the copy; source objects are kept
(not deleted) and reported as reclaimable (count + size) to reclaim manually;
declining routes only new writes to the new backend, leaving old objects in
place as before. Only one storage job (a mirror sync or a category migration)
runs at a time, cancelling before the flip is always safe, and a failed copy
never flips the category; media categories on S3 work but every served byte
proxies through the server — the proxy answers conditional GETs (ETag /
Last-Modified) and a single `Range` request, while a multi-range request is
answered with the full object; AWS buckets
with Object-Lock or checksum-requiring policies
reject the server's uploads; self-hosted endpoints (MinIO/Garage) should be
addressed by IP or `localhost` unless configured for virtual-hosted buckets.
The legacy `/uploads/photos` directory from older TREK versions is still
served and included in backups, but it is not a configurable category.
Running the storage contract suite against a live MinIO/AIStor is a
license-gated procedure — CI runs it automatically for same-repo pull
requests (fork PRs skip it, since they have no access to the license
secret); to run it yourself locally, see `docker-compose.minio-test.yml`'s
header for the manual procedure.
### Provisioning at first boot (seed file)
For infrastructure-as-code setups, mount a `storage-config.json` into the
data directory — it is imported **once**, on the first boot that has no
stored storage configuration, and loudly ignored afterwards:
```jsonc
// storage-config.json — secrets may be plaintext (encrypted on import)
// or already-encrypted enc:v1: values.
{
"backends": [
{ "name": "off-site", "type": "s3", "options": {
"endpoint": "https://s3.example.com", "bucket": "trek",
"accessKeyId": "…", "secretAccessKey": "…" } },
{ "name": "backups-mirror", "type": "mirror", "options": {
"primary": "backups-local", "replicas": ["off-site"] } }
],
"categories": { "backups": "backups-mirror" }
}
```
(The panel presents this exact setup as **Mirror targets** on `backups-local`.)
```yaml
# docker-compose: add under the trek service's volumes
- ./storage-config.json:/app/data/storage-config.json:ro
```
An invalid seed file aborts boot with the exact validation error — an
actively-provisioning operator sees the problem instead of a silent default.
### Recovery and restore
To reset storage configuration to the built-in defaults (or to re-import a
seed file): stop the server, run
`sqlite3 data/travel.db "DELETE FROM app_settings WHERE key LIKE 'storage.%';"`,
and start it again.
Backups include the storage configuration (it lives in the database). If your
only backup sits on S3 and the credentials for it sit inside that backup:
start a fresh instance, enter the S3 credentials in **Admin → Storage** (or
mount a seed file), then restore the backup from the Backup panel.
## Data & Backups
- **Database** — SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/` by default; every content category can be reassigned to another backend in **Admin → Storage**
- **Logs** — `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel
<br />
<a href="https://www.star-history.com/?repos=liketrek%2FTREK&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&theme=dark&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
</picture>
</a>
## Data sources
@@ -609,6 +382,8 @@ The Atlas map's country and sub-national (province/county) boundaries come from
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
for full third-party attributions.
<br />
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
+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: 3.4.1
version: 4.2.0
description: Minimal Helm chart for TREK app
appVersion: "3.4.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
+69
View File
@@ -0,0 +1,69 @@
# CLAUDE.md
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 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 (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: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
## Page pattern (enforced — `lint:pages` fails 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 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 **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/`; `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).
## Rules for new code
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.** 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.
## Big-picture pieces
- **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`, 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()
})
+98
View File
@@ -0,0 +1,98 @@
import { test, expect, devices } from '@playwright/test'
import type { Page } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Phone coverage for #2051: the day details area steps days under a horizontal
// swipe, so the trip is navigable one-handed instead of only from the chip rail
// pinned to the top of the screen.
//
// jsdom can drive the handlers but not the browser around them: whether the
// gesture survives real touch dispatch, whether the browser hands the vertical
// axis to its own scroller first, and whether a swipe at the first day stays put
// are all engine behaviour. This drives CDP's Input.dispatchTouchEvent, so the
// events are the ones a finger produces — not synthesised TouchEvent objects.
//
// Chromium, because CDP is where real touch injection lives. The file-level
// device sets a phone viewport with a coarse pointer, which is what puts the
// mobile shell on screen at all.
test.use({ ...devices['Pixel 7'] })
/** One finger, dispatched through the browser's own input pipeline. */
async function swipe(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 8) {
const cdp = await page.context().newCDPSession(page)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from.x, y: from.y }] })
for (let i = 1; i <= steps; i++) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{
x: from.x + ((to.x - from.x) * i) / steps,
y: from.y + ((to.y - from.y) * i) / steps,
}],
})
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await cdp.detach()
// The out leg is 150ms and the in leg 220ms.
await page.waitForTimeout(500)
}
const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
test('#2051 phone: the day details area swipes between days', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
// Without a phone viewport and a coarse pointer the mobile shell never mounts,
// and everything below would be proving nothing.
const env = await page.evaluate(() => ({
width: window.innerWidth,
coarse: window.matchMedia('(pointer: coarse)').matches,
}))
expect(env.width, 'Pixel sits below the 768px phone breakpoint').toBeLessThan(768)
expect(env.coarse, 'Pixel reports a coarse primary pointer').toBe(true)
const stamp = Date.now()
const created = await page.request.post('/api/trips', {
data: { title: `Day swipe ${stamp}`, start_date: '2099-06-01', end_date: '2099-06-05' },
})
expect(created.ok(), 'seed trip').toBeTruthy()
const tripId = (await created.json()).trip?.id ?? (await created.json()).id
await page.goto(`/trips/${tripId}`)
await expect(activeChip(page)).toBeVisible({ timeout: 30_000 })
const chips = page.locator('button[aria-current], button[aria-current="true"]')
const rail = activeChip(page).locator('xpath=..')
expect(await rail.locator('button').count(), 'the seeded trip has five days').toBe(5)
expect(await chips.count()).toBeGreaterThan(0)
// The swipe zone: below the chip rail, above the dock, across the middle.
const mid = Math.round(env.width / 2)
const y = 460
const day1 = (await activeChip(page).textContent())!.trim()
// 1. A swipe left steps forward.
await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
const day2 = (await activeChip(page).textContent())!.trim()
expect(day2, 'swiping left moved to the next day').not.toBe(day1)
// 2. A swipe right steps back to where it started.
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
// 3. The first day has nowhere to go back to — it must stay put, and the
// browser must not have been handed a page-back either.
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
expect(page.url()).toContain(`/trips/${tripId}`)
// 4. A vertical drag belongs to the card's own scroller, never to the day.
await swipe(page, { x: mid, y: y + 120 }, { x: mid, y: y - 120 })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
// 5. And the rail itself still works, so the gesture added a path rather than
// replacing one.
await rail.locator('button').nth(2).click()
await expect(activeChip(page)).not.toHaveText(day1)
})
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.4.1",
"version": "4.2.0",
"private": true,
"type": "module",
"scripts": {
@@ -37,6 +37,8 @@
"@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": "*",
"axios": "^1.6.7",
+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',
),
};
+32 -14
View File
@@ -2,9 +2,12 @@
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
//
// We scan only the default-export component body (from `export default function`
// up to the next top-level `function` declaration or EOF), so presentational
// sub-components and helper hooks living in the same file are not flagged.
// We scan the default-export component body (from `export default function` up
// to the next top-level `function` declaration or EOF), and the same span of a
// top-level `*PageDesktop`/`*PageMobile` — since ViewportRoute started picking
// the branch, the default export is a two-line shim and the real container is
// one of those two. Everything else at top level stays exempt: presentational
// sub-components and helper hooks live in these files on purpose.
// Context hooks like useTranslation/useParams are fine; the smell is stateful
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
import { readdirSync, readFileSync } from 'node:fs'
@@ -13,23 +16,38 @@ import { fileURLToPath } from 'node:url'
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
// `[<(]` rather than `\(`: a generic call — `useRef<() => void>(null)` — is the
// same thing, and it slipped through for as long as the pattern only knew `(`.
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*[<(]`)
// Declarations the widened scan caught that are not this change's to move.
// Listed by the name they declare rather than by line, so an entry dies with
// its declaration instead of drifting onto whatever follows it. The rest of
// each listed file is still checked.
const KNOWN_ESCAPES = {
'TripPlannerPage.tsx': ['glMap', 'bookingExpense'],
}
const violations = []
for (const file of readdirSync(pagesDir)) {
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
const src = readFileSync(join(pagesDir, file), 'utf8')
const lines = src.split('\n')
const start = lines.findIndex(l => /export default function/.test(l))
if (start === -1) continue
// The page body ends at the next top-level declaration (a `function` at
// column 0) — everything after that is a sub-component or helper.
let end = lines.length
for (let i = start + 1; i < lines.length; i++) {
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
}
for (let i = start; i < end; i++) {
if (bannedRe.test(lines[i])) {
const allowed = KNOWN_ESCAPES[file] ?? []
const starts = []
lines.forEach((line, i) => {
if (/export default function/.test(line) || /^function \w+Page(Desktop|Mobile)\b/.test(line)) starts.push(i)
})
for (const start of starts) {
// The page body ends at the next top-level declaration (a `function` at
// column 0) — everything after that is a sub-component or helper.
let end = lines.length
for (let i = start + 1; i < lines.length; i++) {
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
}
for (let i = start; i < end; i++) {
if (!bannedRe.test(lines[i])) continue
if (allowed.some(name => new RegExp(`\\b${name}\\b`).test(lines[i]))) continue
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
}
}
+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.');
+103 -1
View File
@@ -7,7 +7,8 @@ import { server } from '../tests/helpers/msw/server'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser, buildSettings } from '../tests/helpers/factories'
import { buildUser, buildSettings, buildTrip } from '../tests/helpers/factories'
import { offlineDb } from './db/offlineDb'
import { SETTINGS_WAIT_MS } from './utils/startDestination'
import App from './App'
@@ -142,6 +143,30 @@ describe('RootRedirect — startup destination', () => {
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
it('FE-COMP-APP-028b: opens the cached active trip when the launch is offline', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip' }),
})
const today = new Date()
const iso = (d: Date) => d.toISOString().slice(0, 10)
await offlineDb.trips.put(buildTrip({ id: 42, title: 'Japan', start_date: iso(today), end_date: iso(today) }))
const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, 'onLine')
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
// navigator and the Dexie table are shared with every other test in the
// file, so a failed assertion must not leave the rest of them offline.
try {
renderApp('/')
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
} finally {
if (onLine) Object.defineProperty(Navigator.prototype, 'onLine', onLine)
delete (navigator as unknown as { onLine?: boolean }).onLine
await offlineDb.trips.clear()
}
})
// The whole point of the localStorage mirror: the default launch must not pay
// for a lookup it doesn't need.
it('FE-COMP-APP-029: never asks for the active trip when starting on the dashboard', async () => {
@@ -497,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
+55 -20
View File
@@ -19,7 +19,8 @@ import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi, tripsApi } 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'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
@@ -105,7 +106,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
// A session that ended on its own should come back to where it left off; a
// deliberate sign-out is a fresh start and gets no return ticket, so the
// startup destination decides where the next login lands.
if (loggingOut) return <Navigate to="/login" replace />
if (loggingOut) return <Navigate to="/login" replace state={{ noRedirect: true }} />
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
}
@@ -244,7 +245,9 @@ function RootRedirect() {
: (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB })
if (page !== 'active_trip') { setTarget('/dashboard'); return }
let cancelled = false
tripsApi.active()
// Through the repo, not the api: offline this answers from Dexie instead of
// bouncing a cached trip to the dashboard.
tripRepo.active()
.then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') })
.catch(() => { if (!cancelled) setTarget('/dashboard') })
return () => { cancelled = true }
@@ -280,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()
@@ -297,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)
@@ -311,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(() => {})
}, [])
@@ -377,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,
+46 -5
View File
@@ -111,7 +111,7 @@ describe('client > request interceptor', () => {
expect(sink[0].headers['X-Idempotency-Key']).toBe('queued-key')
})
it('FE-APIWIRE-004: falls back to a random token when crypto.randomUUID is missing', async () => {
it('FE-APIWIRE-004: falls back to getRandomValues when crypto.randomUUID is missing', async () => {
const realCrypto = globalThis.crypto
vi.stubGlobal('crypto', {
getRandomValues: realCrypto.getRandomValues.bind(realCrypto),
@@ -120,9 +120,30 @@ describe('client > request interceptor', () => {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, { adapter: okAdapter(sink) })
const key = String(sink[0].headers['X-Idempotency-Key'])
expect(key).toMatch(/^[a-z0-9]+$/)
expect(key).not.toMatch(/-/)
// randomUUID needs a secure context, so on the plain-http installs the
// README documents this branch is what actually runs. getRandomValues has no
// such requirement, so the fallback is still a full v4 UUID.
expect(String(sink[0].headers['X-Idempotency-Key'])).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
)
})
it('FE-APIWIRE-004b: the last-resort key is never degenerate, even with no Web Crypto at all', async () => {
vi.stubGlobal('crypto', undefined)
const keys = new Set<string>()
for (let i = 0; i < 50; i++) {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, { adapter: okAdapter(sink) })
keys.add(String(sink[0].headers['X-Idempotency-Key']))
}
// The old fallback was Math.random().toString(36).slice(2), which is not
// length-stable: 0.5 yields a single character and 0 yields the empty string.
// An empty key makes the server skip deduplication entirely, so a retried
// write applies twice.
for (const k of keys) expect(k.length).toBeGreaterThan(16)
expect(keys.size).toBe(50)
})
it('FE-APIWIRE-005: the socket id header is omitted while no socket is connected', async () => {
@@ -209,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', () =>
@@ -222,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')
+36 -2
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-052
// FE-APISURF-001 to FE-APISURF-054
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
@@ -9,7 +9,7 @@ import {
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
} from './client'
interface Recorded { method: string; url: string; body: unknown }
@@ -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' },
@@ -396,6 +399,20 @@ describe('client > endpoint wiring', () => {
])
})
it('FE-APISURF-053: memoriesApi maps the photo-provider endpoints', async () => {
await assertCalls([
{ n: 'status', r: () => memoriesApi.status('immich'), e: 'GET /api/integrations/memories/immich/status' },
{ n: 'search', r: () => memoriesApi.search('immich', { from: '2026-01-01', to: '2026-01-02', page: 1, size: 50 }), e: 'POST /api/integrations/memories/immich/search' },
{ n: 'albums', r: () => memoriesApi.albums('immich'), e: 'GET /api/integrations/memories/immich/albums' },
{ n: 'albumPhotos', r: () => memoriesApi.albumPhotos('immich', 'alb-1'), e: 'GET /api/integrations/memories/immich/albums/alb-1/photos' },
])
})
it('FE-APISURF-054: memoriesApi passes the album passphrase as a query parameter', async () => {
const rec = await traceOne(() => memoriesApi.albumPhotos('synologyphotos', 'alb-2', 'p/w?'))
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
})
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
await assertCalls([
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
@@ -848,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 })
}
})
})
+305 -43
View File
@@ -1,6 +1,9 @@
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,
inAppListResultSchema, type InAppListResult,
@@ -29,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,
@@ -52,9 +55,22 @@ 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'
import { downloadBlob } from '../utils/fileDownload'
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
@@ -120,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
@@ -150,10 +166,7 @@ apiClient.interceptors.request.use(
// The mutation queue sets its own pre-generated key; skip if already set.
const method = (config.method ?? '').toLowerCase()
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
const key = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2)
config.headers['X-Idempotency-Key'] = key
config.headers['X-Idempotency-Key'] = randomId()
}
return config
},
@@ -209,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)
}
}
}
}
@@ -316,6 +337,14 @@ export const authApi = {
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
},
// Keys for the public API. Same shape as the MCP tokens above and a separate
// credential: one drives the assistant tools, the other reads trips over HTTP,
// and a key of the wrong kind is refused like one that does not exist.
apiKeys: {
list: () => apiClient.get('/auth/api-tokens').then(r => r.data),
create: (name: string) => apiClient.post('/auth/api-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data),
},
passkey: {
registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data),
registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data),
@@ -411,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),
@@ -440,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[]) =>
@@ -460,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.
@@ -528,11 +569,16 @@ 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),
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
// `version` pins the exact version to install (the rollback path); omitted, the server
// resolves the newest TREK-compatible version itself.
pluginUpdate: (id: string, version?: string) =>
apiClient.post(`/admin/plugins/${id}/update`, version ? { version } : {}).then(r => r.data),
// Release a per-plugin update hold (set by a deliberate non-latest install).
pluginResumeUpdates: (id: string) => apiClient.post(`/admin/plugins/${id}/resume-updates`).then(r => r.data),
// Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the
// full key the admin was shown (not a fingerprint): the server compares it exactly, so
// it can refuse if the registry entry was re-keyed again since the dialog rendered.
@@ -544,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.
@@ -604,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),
@@ -770,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).
@@ -850,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).
@@ -968,11 +1028,160 @@ export const journeyApi = {
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
}
// Photo providers (Immich, Synology Photos, …) behind /api/integrations/memories.
// The provider sits on the user's own hardware and the server proxies through to
// it, so the 8s default does not apply — and neither does any number picked
// here. The server already holds the deadline, and its worst case is longer than
// anything that would look reasonable in this file: a Synology call is 30s, and
// an expired session makes it re-authenticate and try again. A client cap below
// that turns a slow NAS into a NAS that reads as disconnected, which is what
// these calls looked like before they moved off raw fetch (which had no timeout
// either). Callers that need to give up early pass an AbortSignal.
const MEMORIES_TIMEOUT = 0
export const memoriesApi = {
status: (provider: string): Promise<{ connected: boolean }> =>
apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) =>
apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data),
albums: (provider: string) =>
apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
albumPhotos: (provider: string, albumId: string, passphrase?: string, signal?: AbortSignal) =>
apiClient.get(`/integrations/memories/${provider}/albums/${albumId}/photos`, {
timeout: MEMORIES_TIMEOUT,
params: passphrase ? { passphrase } : undefined,
signal,
}).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
@@ -992,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),
@@ -1070,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')),
}
@@ -1144,13 +1404,7 @@ export const backupApi = {
credentials: 'include',
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
downloadBlob(await res.blob(), filename)
},
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
@@ -1170,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),
}
@@ -1188,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),
+49 -4
View File
@@ -1,6 +1,19 @@
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-014
import { describe, it, expect } from 'vitest'
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup, PRESET_OPT_IN_ONLY, PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from './oauthScopes'
// The consent page mirrors the server's scope list by hand, so read the server
// file and compare. Parsing keeps this a client-only test (no server import).
function readServerScopes(): string[] {
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(resolve(here, '../../../server/src/mcp/scopes.ts'), 'utf8')
const block = src.slice(src.indexOf('export const SCOPES = {'))
const body = block.slice(0, block.indexOf('} as const;'))
return [...body.matchAll(/^\s*[A-Z_]+:\s*'([a-z]+:[a-z]+)'/gm)].map(m => m[1])
}
describe('SCOPE_GROUPS', () => {
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
@@ -18,6 +31,7 @@ describe('SCOPE_GROUPS', () => {
'vacay:read', 'vacay:write',
'geo:read', 'weather:read',
'journey:read', 'journey:write', 'journey:share',
'plugins:use',
]
for (const scope of expected) {
expect(SCOPE_GROUPS).toHaveProperty(scope)
@@ -34,8 +48,16 @@ describe('SCOPE_GROUPS', () => {
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => {
expect(ALL_SCOPES).toHaveLength(29)
it('FE-OAUTH-SCOPES-003: contains exactly as many scopes as the server defines', () => {
expect(ALL_SCOPES).toHaveLength(readServerScopes().length)
})
it('FE-OAUTH-SCOPES-011: matches server/src/mcp/scopes.ts exactly', () => {
const serverScopes = readServerScopes()
// Guards the parse itself: a moved file or a reformatted list must fail
// here rather than quietly compare two empty sets.
expect(serverScopes.length).toBeGreaterThan(0)
expect(new Set(ALL_SCOPES)).toEqual(new Set(serverScopes))
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
@@ -102,3 +124,26 @@ describe('getScopesByGroup', () => {
expect(groups['oauth.scope.group.trips']).toBeUndefined()
})
})
describe('client presets', () => {
// The presets are written as "everything except deletes", so a new scope joins
// them silently. That is right for a scope over the user's own data and wrong
// for one that runs third-party code, so the exclusion gets a test.
it('FE-OAUTH-SCOPES-012: never offers an opt-in-only scope by default', () => {
expect(PRESET_OPT_IN_ONLY.size).toBeGreaterThan(0)
for (const scope of PRESET_OPT_IN_ONLY) {
expect(PRESET_SCOPES_DEFAULT).not.toContain(scope)
expect(PRESET_SCOPES_READONLY).not.toContain(scope)
}
})
it('FE-OAUTH-SCOPES-013: still offers every other non-destructive scope', () => {
const expected = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
expect(PRESET_SCOPES_DEFAULT).toEqual(expected)
expect(PRESET_SCOPES_DEFAULT).toContain('trips:write')
})
it('FE-OAUTH-SCOPES-014: the read-only preset stays read-only', () => {
expect(PRESET_SCOPES_READONLY.every(s => s.endsWith(':read'))).toBe(true)
})
})
+22
View File
@@ -43,10 +43,32 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
'files:read': { labelKey: 'oauth.scope.files:read.label', descriptionKey: 'oauth.scope.files:read.description', groupKey: 'oauth.scope.group.files' },
'files:write': { labelKey: 'oauth.scope.files:write.label', descriptionKey: 'oauth.scope.files:write.description', groupKey: 'oauth.scope.group.files' },
'files:content': { labelKey: 'oauth.scope.files:content.label', descriptionKey: 'oauth.scope.files:content.description', groupKey: 'oauth.scope.group.files' },
'settings:read': { labelKey: 'oauth.scope.settings:read.label', descriptionKey: 'oauth.scope.settings:read.description', groupKey: 'oauth.scope.group.settings' },
'settings:write': { labelKey: 'oauth.scope.settings:write.label', descriptionKey: 'oauth.scope.settings:write.description', groupKey: 'oauth.scope.group.settings' },
'plugins:use': { labelKey: 'oauth.scope.plugins:use.label', descriptionKey: 'oauth.scope.plugins:use.description', groupKey: 'oauth.scope.group.plugins' },
}
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
/**
* Scopes a client preset must never tick for you.
*
* The presets are written as "everything except deletes", so a new scope joins
* them silently. That is right for a new read or write scope on data the user
* already owns, and wrong for plugins:use: it turns on third-party tool
* execution, which nobody would have chosen by installing an editor.
*/
export const PRESET_OPT_IN_ONLY = new Set(['plugins:use'])
/** The five full-access presets: everything destructive or opt-in-only removed. */
export const PRESET_SCOPES_DEFAULT = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
/** The read-only preset (VS Code). Kept beside the default so the pair stays visible. */
export const PRESET_SCOPES_READONLY = ALL_SCOPES.filter(s => s.endsWith(':read') && !PRESET_OPT_IN_ONLY.has(s))
// Group all scopes for the client registration form
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))]
+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',
+110 -40
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', () => {
@@ -98,7 +95,7 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
render(<AddonManager />);
await screen.findByText('No addons available');
expect(await screen.findByText('No addons available')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
@@ -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 -350
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 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 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
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,130 +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">
<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 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
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 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 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 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`
@@ -554,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
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>
)
}
@@ -49,7 +49,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => {
render(<AdminMcpTokensPanel />);
await screen.findByText('No MCP tokens have been created yet');
expect(await screen.findByText('No MCP tokens have been created yet')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
@@ -195,7 +195,7 @@ describe('AdminMcpTokensPanel', () => {
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Failed to load tokens');
expect(await screen.findByText('Failed to load tokens')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
@@ -216,7 +216,7 @@ describe('AdminMcpTokensPanel', () => {
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('No active OAuth sessions');
expect(await screen.findByText('No active OAuth sessions')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
@@ -318,6 +318,6 @@ describe('AdminMcpTokensPanel', () => {
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await screen.findByText('Failed to revoke session');
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
});
});
@@ -126,13 +126,13 @@ export default function AdminMcpTokensPanel() {
</span>
))}
{!expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
<button type="button" onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
+{hidden} more
</button>
)}
{expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
<button type="button" onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
show less
</button>
@@ -146,7 +146,7 @@ export default function AdminMcpTokensPanel() {
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{new Date(session.created_at).toLocaleDateString(locale)}
</span>
<button onClick={() => setRevokeConfirmId(session.id)}
<button type="button" onClick={() => setRevokeConfirmId(session.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
@@ -199,7 +199,7 @@ export default function AdminMcpTokensPanel() {
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => setDeleteConfirmId(token.id)}
<button type="button" onClick={() => setDeleteConfirmId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
@@ -214,16 +214,21 @@ export default function AdminMcpTokensPanel() {
{/* Revoke OAuth session modal */}
{revokeConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
role="button" tabIndex={0} aria-label={t('common.close')}
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}
onKeyDown={e => {
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) }
}}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setRevokeConfirmId(null)}
<button type="button" onClick={() => setRevokeConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button onClick={() => handleRevoke(revokeConfirmId)}
<button type="button" onClick={() => handleRevoke(revokeConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('common.delete')}
</button>
@@ -235,16 +240,21 @@ export default function AdminMcpTokensPanel() {
{/* Delete MCP token modal */}
{deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
role="button" tabIndex={0} aria-label={t('common.close')}
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}
onKeyDown={e => {
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) }
}}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setDeleteConfirmId(null)}
<button type="button" onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button onClick={() => handleDelete(deleteConfirmId)}
<button type="button" onClick={() => handleDelete(deleteConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('common.delete')}
</button>
@@ -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 })
@@ -201,6 +203,9 @@ function registryEntry(over: Record<string, unknown> = {}) {
id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify',
type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null,
screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
// The real browse response always carries latestCompatible (server-side hostCompat);
// mirror `latest` by default, exactly like a fully-compatible entry.
latestCompatible: (over.latest as string | undefined) ?? '2.0.0',
...over,
}
}
@@ -278,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.
@@ -840,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', () => {
@@ -944,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)
@@ -964,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()
@@ -1087,6 +1129,235 @@ describe('AdminPluginsPanel — updates', () => {
})
})
/**
* Update availability must follow what this TREK can actually INSTALL (latestCompatible,
* computed server-side), never the absolute newest published version — a banner counting
* versions the update endpoint would refuse nags the admin toward a guaranteed 400.
*/
describe('AdminPluginsPanel — compatible updates only', () => {
const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false })
it('FE-COMP-PLUGINS-UPD-001: an update this TREK cannot install is neither offered nor counted', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-002: the update offer is the newest compatible version, not the absolute latest', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-003: a newer version needing a newer TREK leaves a passive hint on the row', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-004: a legacy entry with only minTrekVersion still gets a hint range', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: null, minTrekVersion: '4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-005: no hint when the latest version is the one on offer', async () => {
panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] })
render(<AdminPluginsPanel />)
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
expect(screen.queryByText(/needs TREK/i)).not.toBeInTheDocument()
})
})
/**
* The update hold (#plugins): a deliberate non-latest install sets updateHold on the row,
* which takes it out of the banner count and "Update all" — a rollback the admin just
* made must not be nagged straight back. The row says so and offers to resume.
*/
describe('AdminPluginsPanel — update hold', () => {
const held = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false, updateHold: true })
it('FE-COMP-PLUGINS-HOLD-001: a held plugin leaves the banner and loses its update button', async () => {
panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-HOLD-002: Resume updates releases the hold on the server', async () => {
let resumed = false
panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => {
resumed = true
return HttpResponse.json({ updateHold: false })
}))
withToast()
fireEvent.click(await screen.findByRole('button', { name: /resume updates/i }))
await waitFor(() => expect(resumed).toBe(true))
expect(await screen.findByText('Updates resumed')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-HOLD-003: Update all skips held plugins', async () => {
const updated: string[] = []
panelWith(
[held, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })],
{ registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] },
)
server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => {
updated.push(String(params.id))
return HttpResponse.json({ version: '3.0.0', activated: true, newPermissions: [], newEgress: [] })
}))
withToast()
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /update all/i }))
await waitFor(() => expect(updated).toEqual(['trek-ntfy']))
})
})
/**
* The version picker (#plugins): install any published version from the Discover detail
* modal, and roll an installed plugin back through the update pipeline (which owns the
* stop-child + re-consent machinery). Compat verdicts are SERVER-computed per version —
* the picker only renders them, it never re-derives range logic.
*/
describe('AdminPluginsPanel — version picker', () => {
const VERSIONS = [
{ version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
]
const detailWithVersions = () => ({
...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }),
size: 1024, publishedAt: null, manifest: null, versions: VERSIONS,
})
function mockDiscoverDetail() {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
http.get('*/api/admin/plugins/registry', () =>
HttpResponse.json([registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })])),
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
)
}
it('FE-COMP-PLUGINS-VPICK-001: the detail modal lists every version and installs the picked one', async () => {
let body: unknown = null
mockDiscoverDetail()
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' })
}))
withToast()
const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
fireEvent.click(tabs[0])
fireEvent.click(await screen.findByText('Gotify'))
fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }))
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }))
})
it('FE-COMP-PLUGINS-VPICK-002: an incompatible version is greyed with its TREK requirement, not installable', async () => {
mockDiscoverDetail()
render(<AdminPluginsPanel />)
const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
fireEvent.click(tabs[0])
fireEvent.click(await screen.findByText('Gotify'))
expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-VPICK-003: Change version rolls back through update, after an explicit data warning', async () => {
let body: unknown = null
panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false })], {
registry: [registryEntry({ latest: '2.0.0' })],
})
server.use(
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] })
}),
)
withToast()
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i }))
// The rollback keeps the plugin's data — the older code may not read it. Informed consent, no snapshot.
expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /roll back/i }))
await waitFor(() => expect(body).toEqual({ version: '1.5.0' }))
})
it('FE-COMP-PLUGINS-VPICK-004: picking a NEWER version in the picker updates without the downgrade warning', async () => {
let body: unknown = null
panelWith([plugin({ source_repo: 'acme/gotify', version: '1.5.0', operatorEgress: false })], {
registry: [registryEntry({ latest: '2.0.0' })],
})
server.use(
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
}),
)
withToast()
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
fireEvent.click(await screen.findByRole('button', { name: /^switch to 2\.0\.0$/i }))
await waitFor(() => expect(body).toEqual({ version: '2.0.0' }))
expect(screen.queryByText(/data written by the newer version stays in place/i)).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-VPICK-005: the installed version is marked and not switchable; sideloads get no picker', async () => {
panelWith([
plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false }),
plugin({ id: 'side-load', name: 'Sideload', source_repo: 'local:upload', version: '1.0.0', operatorEgress: false }),
], { registry: [registryEntry({ latest: '2.0.0' })] })
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())))
withToast()
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-side-load'))
expect(screen.queryByRole('button', { name: /change version/i })).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('plugin-row-menu-btn-side-load')) // close
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
const current = await screen.findByTestId('version-row-2.0.0')
expect(within(current).getByText('Installed')).toBeInTheDocument()
expect(within(current).queryByRole('button', { name: /switch/i })).not.toBeInTheDocument()
})
})
describe('AdminPluginsPanel — capability and dependency chips', () => {
it('FE-COMP-PLUGINS-PANEL-035: a permission-rich plugin renders one chip per capability', async () => {
panelWith([plugin({
@@ -1207,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' },
@@ -1405,7 +1688,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
it('FE-W5PLG-005: the update filter and the updates-first sort both use the registry version', async () => {
const user = userEvent.setup()
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
@@ -1423,7 +1706,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
it('FE-W5PLG-006: switching tabs snaps a tab-only sort key back to name', async () => {
const user = userEvent.setup()
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
@@ -1531,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: [] })),
)
@@ -1546,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 })),
)
@@ -1862,3 +2147,397 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => {
await waitFor(() => expect(uploaded).toBe(false))
})
})
// FE-W5PLG-030 — Update all drives the same single-slot busy flag as a per-row update,
// so the requests have to go out one after another.
describe('AdminPluginsPanel — update all', () => {
const updatableRow = (over: Record<string, unknown> = {}) => plugin({
id: 'a-widget', name: 'Alpha Widget', type: 'widget', version: '1.0.0', operatorEgress: false, ...over,
})
it('FE-W5PLG-030: Update all sends the second update only after the first came back', async () => {
const started: string[] = []
let release = () => {}
const gate = new Promise<void>(resolve => { release = resolve })
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({
enabled: true, devLink: false,
plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })],
})),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([
{ id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
{ id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
])),
http.post('*/api/admin/plugins/:id/update', async ({ params }) => {
started.push(String(params.id))
if (started.length === 1) await gate
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
}),
)
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /update all/i }))
await waitFor(() => expect(started).toHaveLength(1))
// Give a parallel dispatch every chance to show up before releasing the first.
await new Promise(resolve => setTimeout(resolve, 20))
expect(started).toEqual(['a-widget'])
release()
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()
})
})
File diff suppressed because it is too large Load Diff
+23 -19
View File
@@ -191,7 +191,7 @@ export default function BackupPanel() {
</div>
</div>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={loadBackups}
disabled={isLoading}
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
@@ -208,7 +208,7 @@ export default function BackupPanel() {
className="hidden"
onChange={handleUploadRestore}
/>
<button
<button type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
@@ -222,7 +222,7 @@ export default function BackupPanel() {
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
</button>
<button
<button type="button"
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
@@ -247,7 +247,7 @@ export default function BackupPanel() {
<div className="text-center py-12 text-gray-400">
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
<p className="text-sm">{t('backup.empty')}</p>
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
<button type="button" onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
{t('backup.createFirst')}
</button>
</div>
@@ -274,14 +274,14 @@ export default function BackupPanel() {
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
<button type="button"
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
>
<Download className="w-3.5 h-3.5" />
{t('backup.download')}
</button>
<button
<button type="button"
onClick={() => handleRestore(backup.filename)}
disabled={restoringFile === backup.filename}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
@@ -292,7 +292,7 @@ export default function BackupPanel() {
}
{t('backup.restore')}
</button>
<button
<button type="button"
onClick={() => handleDelete(backup.filename)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
>
@@ -318,11 +318,11 @@ export default function BackupPanel() {
<div className="flex flex-col gap-5">
{/* Enable toggle */}
<label className="flex items-center justify-between gap-4 cursor-pointer">
<div className="min-w-0">
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
<div className="min-w-0 text-sm font-medium text-gray-900">
{t('backup.auto.enable')}
<p className="text-xs font-normal text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
</div>
<button
<button type="button"
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
@@ -339,7 +339,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
<div className="flex flex-wrap gap-2">
{INTERVAL_OPTIONS.map(opt => (
<button
<button type="button"
key={opt.value}
onClick={() => handleAutoSettingsChange('interval', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
@@ -360,7 +360,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
<CustomSelect
value={String(autoSettings.hour)}
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
size="sm"
options={HOURS.map(h => {
let label: string
@@ -386,7 +386,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
<div className="flex flex-wrap gap-2">
{DAYS_OF_WEEK.map(opt => (
<button
<button type="button"
key={opt.value}
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
@@ -408,7 +408,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
<CustomSelect
value={String(autoSettings.day_of_month)}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
size="sm"
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
/>
@@ -421,7 +421,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
<div className="flex flex-wrap gap-2">
{KEEP_OPTIONS.map(opt => (
<button
<button type="button"
key={opt.value}
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
@@ -440,7 +440,7 @@ export default function BackupPanel() {
{/* Save button */}
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
<button type="button"
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
@@ -458,11 +458,15 @@ export default function BackupPanel() {
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
// Dismiss-on-backdrop is a mouse shortcut for the Cancel button below;
// the backdrop itself carries no semantics of its own.
role="presentation"
className="bg-[rgba(0,0,0,0.5)]"
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setRestoreConfirm(null)}
>
<div
role="presentation"
onClick={e => e.stopPropagation()}
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
@@ -497,14 +501,14 @@ export default function BackupPanel() {
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
<button type="button"
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
<button type="button"
onClick={executeRestore}
className="bg-[#dc2626] text-white"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
@@ -27,17 +27,17 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-002: shows Categories title', async () => {
render(<CategoryManager />);
await screen.findByText('Categories');
expect(await screen.findByText('Categories')).toBeInTheDocument();
});
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
render(<CategoryManager />);
await screen.findByText('No categories yet');
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
});
it('FE-COMP-CAT-004: shows New Category button', async () => {
render(<CategoryManager />);
await screen.findByText('New Category');
expect(await screen.findByText('New Category')).toBeInTheDocument();
});
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
@@ -129,7 +129,7 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-010: shows subtitle text', async () => {
render(<CategoryManager />);
await screen.findByText('Manage categories for places');
expect(await screen.findByText('Manage categories for places')).toBeInTheDocument();
});
it('FE-COMP-CAT-011: category count is shown', async () => {
@@ -194,7 +194,7 @@ export default function CategoryManager() {
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
</div>
<button onClick={handleStartCreate}
<button type="button" onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('categories.new')}</span>
@@ -235,11 +235,11 @@ export default function CategoryManager() {
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleStartEdit(cat)}
<button type="button" onClick={() => handleStartEdit(cat)}
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(cat.id)}
<button type="button" onClick={() => handleDelete(cat.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
<Trash2 className="w-4 h-4" />
</button>
@@ -1,4 +1,4 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-025
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-027
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -63,6 +63,11 @@ function hasResetLink(label: string): boolean {
return !!el && within(el).queryByRole('button') !== null;
}
/** The CARTO key input sits under its label inside the field's own wrapper. */
function cartoInput(): HTMLInputElement {
return within(screen.getByText('Shared CARTO key').closest('div') as HTMLElement).getByRole('textbox');
}
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
await user.click(screen.getByRole('button', { name: trigger }));
@@ -419,4 +424,32 @@ describe('DefaultUserSettingsTab', () => {
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-026: the shared CARTO key is stored on blur and cleared by its reset link', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ carto_api_key: 'ck.old' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Shared CARTO key');
const input = cartoInput();
expect(input).toHaveValue('ck.old');
await user.clear(input);
await user.type(input, 'ck.new');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ carto_api_key: 'ck.new' }]));
// Clicking the reset link also blurs the field again, so only the last PUT is checked
await user.click(resetLink('Shared CARTO key'));
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ carto_api_key: null }));
await waitFor(() => expect(cartoInput()).toHaveValue(''));
});
it('FE-ADMIN-DUS-027: a managed instance hides the shared CARTO key field', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
stubDefaults({ carto_api_key: 'ck.hoster' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument();
});
});
@@ -8,7 +8,7 @@ import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, Place } from '../../types'
import { normalizeTileUrl } from '../../utils/tileUrl'
import { normalizeTileUrl, withTileApiKey } from '../../utils/tileUrl'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
@@ -23,6 +23,12 @@ import { useAuthStore } from '../../store/authStore'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
// The app default, and a vector style rather than a {z}/{x}/{y} template: no
// key, no registration, no request limits.
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
// CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so
// these two need one; without it the map falls back to the default (#2054).
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
@@ -36,6 +42,8 @@ type Defaults = {
default_currency?: string
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
@@ -86,7 +94,7 @@ function OptionButton({
children: React.ReactNode
}) {
return (
<button
<button type="button"
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -111,6 +119,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [mapTileUrl, setMapTileUrl] = useState('')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [routingBase, setRoutingBase] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
@@ -119,6 +129,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(data)
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))
@@ -140,6 +152,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(updated)
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))
@@ -154,7 +168,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
<button type="button"
onClick={() => reset(field)}
className="text-xs ml-2 text-content-faint underline"
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
@@ -185,7 +199,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
transport_mode: null,
website: null,
phone: null,
created_at: Date(),
created_at: String(new Date()),
}], [])
if (!loaded) {
@@ -335,6 +349,52 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
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.mapDefaultHint')}</p>
{/* The key comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.cartoKey')}
<ResetButton field="carto_api_key" />
</label>
<input
type="text"
value={cartoKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
onBlur={() => save({ carto_api_key: cartoKey })}
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('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, {
@@ -348,7 +408,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
onMapContextMenu: null,
center: [48.8566, 2.3522],
zoom: 10,
tileUrl: mapTileUrl,
// Same as the user-facing map tab: the field holds what is being
// edited, so the key goes back on before the preview resolves it.
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
@@ -87,7 +87,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Sent: simple-me');
expect(await screen.findByText('Sent: simple-me')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
@@ -101,8 +101,8 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
// Fire the click but do not await — handler never resolves so sending stays true
void user.click(screen.getByText('Simple → Me').closest('button')!);
// The request handler never resolves, so sending stays true after the click settles
await user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
@@ -120,7 +120,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('No channel configured');
expect(await screen.findByText('No channel configured')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
@@ -133,7 +133,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Failed');
expect(await screen.findByText('Failed')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
@@ -66,7 +66,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
}: {
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
}) => (
<button
<button type="button"
onClick={onClick}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
@@ -231,7 +231,7 @@ describe('GitHubPanel', () => {
render(<GitHubPanel />);
await screen.findByText('v1.7.0');
await user.click(screen.getByText('Show details'));
await screen.findByText('This is a plain paragraph without any markdown syntax.');
expect(await screen.findByText('This is a plain paragraph without any markdown syntax.')).toBeInTheDocument();
});
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
+2 -2
View File
@@ -461,7 +461,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
<button type="button"
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
>
@@ -484,7 +484,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
{/* Load more */}
{hasMore && (
<div className="pt-2 text-center">
<button
<button type="button"
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
@@ -18,7 +18,7 @@ beforeEach(() => {
resetAllStores();
});
/** Template rows carry [chevron, edit, delete]; category headers [add item, edit, delete]. */
/** Template rows carry [chevron, name, edit, delete]; category headers [add item, edit, delete]. */
function rowButtons(name: string, selector: string): HTMLElement[] {
const row = screen.getByText(name).closest(selector) as HTMLElement;
return within(row).getAllByRole('button');
@@ -250,7 +250,7 @@ describe('PackingTemplateManager', () => {
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, 'Electronics{Enter}');
await screen.findByText('Electronics');
expect(await screen.findByText('Electronics')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
@@ -280,7 +280,7 @@ describe('PackingTemplateManager', () => {
await user.type(itemInput, 'Sandals');
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
await user.type(itemInput, '{Enter}');
await screen.findByText('Sandals');
expect(await screen.findByText('Sandals')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
@@ -312,7 +312,7 @@ describe('PackingTemplateManager', () => {
const catInput = screen.getByDisplayValue('Clothing');
await user.clear(catInput);
await user.type(catInput, 'Shoes{Enter}');
await screen.findByText('Shoes');
expect(await screen.findByText('Shoes')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
@@ -379,7 +379,7 @@ describe('PackingTemplateManager', () => {
const input = screen.getByDisplayValue('T-shirt');
await user.clear(input);
await user.type(input, 'Tank Top{Enter}');
await screen.findByText('Tank Top');
expect(await screen.findByText('Tank Top')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
@@ -588,11 +588,11 @@ describe('PackingTemplateManager', () => {
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(templateButtons('Beach Trip')[2]);
await user.click(templateButtons('Beach Trip')[3]);
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument();
expect(screen.getByText('Clothing')).toBeInTheDocument();
await user.click(templateButtons('Beach Trip')[2]);
await user.click(templateButtons('Beach Trip')[3]);
await screen.findByText('Template deleted');
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
@@ -611,14 +611,14 @@ describe('PackingTemplateManager', () => {
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('Beach Trip');
await user.click(templateButtons('Beach Trip')[1]);
await user.click(templateButtons('Beach Trip')[2]);
await user.clear(screen.getByDisplayValue('Beach Trip'));
await user.type(screen.getByRole('textbox'), '{Enter}');
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
expect(puts).toBe(0);
// Blurring the field commits the pending name — here the request fails
await user.click(templateButtons('Beach Trip')[1]);
await user.click(templateButtons('Beach Trip')[2]);
const input = screen.getByDisplayValue('Beach Trip');
await user.clear(input);
await user.type(input, 'Winter');
@@ -158,7 +158,7 @@ export default function PackingTemplateManager() {
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
</div>
<button onClick={() => setShowCreate(true)}
<button type="button" onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
</button>
@@ -171,8 +171,8 @@ export default function PackingTemplateManager() {
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
<button type="button" onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
<button type="button" onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
</div>
)}
@@ -187,7 +187,7 @@ export default function PackingTemplateManager() {
<div key={tmpl.id}>
{/* Template row */}
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
<Package size={16} className="text-slate-400 flex-shrink-0" />
@@ -197,14 +197,14 @@ export default function PackingTemplateManager() {
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
) : (
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-left bg-transparent border-0 p-0 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</button>
)}
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
</span>
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
<button onClick={() => handleDeleteTemplate(tmpl.id)}
<button type="button" onClick={() => handleDeleteTemplate(tmpl.id)}
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
</div>
@@ -228,11 +228,11 @@ export default function PackingTemplateManager() {
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
)}
<span className="text-xs text-slate-400">{catItems.length}</span>
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
<button onClick={() => handleDeleteCategory(cat.id)}
<button type="button" onClick={() => handleDeleteCategory(cat.id)}
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
</div>
@@ -246,15 +246,15 @@ export default function PackingTemplateManager() {
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
<button type="button" onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
<button type="button" onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
</>
) : (
<>
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
<button onClick={() => handleDeleteItem(item.id)}
<button type="button" onClick={() => handleDeleteItem(item.id)}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
</>
)}
@@ -268,9 +268,9 @@ export default function PackingTemplateManager() {
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
placeholder={t('admin.packingTemplates.itemName')}
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
</div>
)}
@@ -287,11 +287,11 @@ export default function PackingTemplateManager() {
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
placeholder={t('admin.packingTemplates.categoryName')}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
<button type="button" onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
</div>
) : (
<button onClick={() => setAddingCategory(true)}
<button type="button" onClick={() => setAddingCategory(true)}
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
</button>
@@ -269,6 +269,6 @@ describe('PermissionsPanel', () => {
),
);
renderPanel();
await screen.findByText('Error');
expect(await screen.findByText('Error')).toBeInTheDocument();
});
});
@@ -104,7 +104,7 @@ export default function PermissionsPanel(): React.ReactElement {
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={handleReset}
disabled={saving}
title={t('perm.resetDefaults')}
@@ -114,7 +114,7 @@ export default function PermissionsPanel(): React.ReactElement {
<RotateCcw className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
</button>
<button
<button type="button"
onClick={handleSave}
disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
@@ -605,7 +605,12 @@ describe('AdminStoragePanel', () => {
await screen.findByText('Storage configuration saved');
// files is default-sourced (uploads-local) in baseState() — stripping restores "no override".
expect((putBody as StorageConfig).categories.files).toBeUndefined();
expect(migrationBody).toEqual({ category: 'files', to: 'off-box' });
// Waited for, not read straight after the toast. That toast comes from the
// PUT, while the migration POST is fired by a later effect reacting to the
// refreshed admin.state, so the two are not the same tick. Reading it
// immediately only held while that effect happened to flush first, which
// under load it does not.
await waitFor(() => expect(migrationBody).toEqual({ category: 'files', to: 'off-box' }));
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
@@ -676,7 +681,8 @@ describe('AdminStoragePanel', () => {
await screen.findByText('Storage configuration saved');
// The POST carries the mirror's raw wire name, not 'backups-local' —
// posting the bare primary would silently drop replication.
expect(migrationBody).toEqual({ category: 'files', to: 'mirror' });
// Waited for, for the same reason as FE-ADMIN-STOR-030 above.
await waitFor(() => expect(migrationBody).toEqual({ category: 'files', to: 'mirror' }));
});
it('FE-ADMIN-STOR-037: a queued migration waits out a running backfill (the server 409s on either while the other runs)', async () => {
@@ -126,8 +126,7 @@ export default function AdminStoragePanel(): React.ReactElement {
useEffect(() => {
if (migrationQueue.length === 0 || !admin.state) return
if (migrationStartInFlight.current) return
if (admin.state.migrations.some((m) => m.status === 'running')) return
if (admin.state.backfills.some((b) => b.status === 'running')) return
if (admin.storageBusy()) return
const [next, ...rest] = migrationQueue
migrationStartInFlight.current = true
setMigrationQueue(rest)
@@ -338,7 +337,7 @@ export default function AdminStoragePanel(): React.ReactElement {
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
: t('storage.usage.never')}
{' '}
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
</button>
</p>
@@ -364,7 +363,7 @@ export default function AdminStoragePanel(): React.ReactElement {
{t(`storage.source.${row.source}`)}
</span>
<span className="flex-1" />
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() =>
@@ -376,12 +375,12 @@ export default function AdminStoragePanel(): React.ReactElement {
{t('storage.actions.test')}
</button>
{row.source !== 'env' && (
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
{t('storage.actions.edit')}
</button>
)}
{row.source === 'settings' && (
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
{t('storage.actions.remove')}
</button>
)}
@@ -433,7 +432,7 @@ export default function AdminStoragePanel(): React.ReactElement {
failed: String(backfill.failed),
})}
</p>
<button
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => handleCancelBackfill(row)}
@@ -464,7 +463,7 @@ export default function AdminStoragePanel(): React.ReactElement {
</p>
)}
{syncPrompt !== row.name && (
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
@@ -480,14 +479,14 @@ export default function AdminStoragePanel(): React.ReactElement {
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
<div className="flex items-center gap-3 mt-1">
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
>
{t('storage.sync.now')}
</button>
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setSyncPrompt(null)}
@@ -512,10 +511,10 @@ export default function AdminStoragePanel(): React.ReactElement {
{t('storage.type.mirror')}
</span>
<span className="flex-1" />
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
{t('storage.actions.test')}
</button>
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
{t('storage.actions.remove')}
</button>
</div>
@@ -552,7 +551,7 @@ export default function AdminStoragePanel(): React.ReactElement {
total: String(m.total),
})}
</p>
<button
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void handleCancelMigration(m)}
@@ -601,7 +600,7 @@ export default function AdminStoragePanel(): React.ReactElement {
onCancel={() => setEditing(null)}
/>
) : (
<button
<button type="button"
onClick={() =>
setEditing({
initial: null,
@@ -675,7 +674,7 @@ export default function AdminStoragePanel(): React.ReactElement {
</Section>
<div className="flex items-center gap-3">
<button
<button type="button"
onClick={save}
disabled={!admin.dirty || admin.saving}
style={{
@@ -715,21 +714,21 @@ export default function AdminStoragePanel(): React.ReactElement {
</p>
))}
<div className="flex items-center gap-3 mt-2">
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void moveAndSave()}
>
{t('storage.migrate.move')}
</button>
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void routeOnlySave()}
>
{t('storage.migrate.routeOnly')}
</button>
<button
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setMigratePromptOpen(false)}
@@ -746,7 +745,7 @@ export default function AdminStoragePanel(): React.ReactElement {
past; "save again" can never clear it, so the banner offers the
one action that can — see useStorageAdmin.discardDraft. */}
{admin.saveConflict && (
<button
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void admin.discardDraft()}
@@ -236,7 +236,7 @@ export default function BackendForm({
)}
<div className="flex items-center gap-3">
<button
<button type="button"
onClick={apply}
disabled={!canApply}
style={{
@@ -248,7 +248,7 @@ export default function BackendForm({
>
{t('storage.form.apply')}
</button>
<button
<button type="button"
onClick={onCancel}
style={{
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
@@ -0,0 +1,96 @@
import React, { useEffect } from 'react';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import type { StorageAdminState } from '@trek/shared';
import { server } from '../../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { useStorageAdmin, type StorageAdmin } from './useStorageAdmin';
function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminState {
return {
backends: [
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] },
],
categories: { files: { backend: 'uploads-local', source: 'default' } } as StorageAdminState['categories'],
health: { replicaFailures: [] },
seedFilePresent: false,
usage: null,
backfills: [],
migrations: [],
version: 0,
...overrides,
};
}
function runningState(): StorageAdminState {
return baseState({
migrations: [
{ category: 'files', from: 'uploads-local', to: 'off-box', status: 'running', done: 1, total: 3, copied: 1, skipped: 0, failed: 0, startedAt: 1 },
],
});
}
/**
* Exposes the hook to the test without a panel in the way — what the panels'
* queue effect depends on is the hook's own timing, not any rendered output.
*/
function Probe({ onReady }: { onReady: (admin: StorageAdmin) => void }): React.ReactElement {
const admin = useStorageAdmin('generic', 'conflict');
// After every render, not during one: the test wants whatever the latest
// render produced, and a render must stay free of side effects.
useEffect(() => { onReady(admin); });
return <div data-testid="ready">{admin.state ? 'loaded' : 'loading'}</div>;
}
describe('useStorageAdmin', () => {
async function mount(): Promise<() => StorageAdmin> {
let latest: StorageAdmin | null = null;
render(<Probe onReady={(a) => { latest = a; }} />);
await screen.findByText('loaded');
return () => latest!;
}
it('FE-ADMIN-STOR-048: startMigration resolves only once storageBusy() already reports the started migration', async () => {
let posted = false;
server.use(
http.get('/api/admin/storage', () => HttpResponse.json(posted ? runningState() : baseState())),
http.post('/api/admin/storage/migrations', () => {
posted = true;
return HttpResponse.json({ started: true });
}),
);
const admin = await mount();
expect(admin().storageBusy()).toBe(false);
// The queue effect releases its single-flight lock the moment this
// resolves, so the world it then decides on must already be post-POST.
expect(await admin().startMigration('files', 'off-box')).toBeNull();
expect(admin().storageBusy()).toBe(true);
});
it('FE-ADMIN-STOR-049: storageBusy() is current at response time, before the render that shows it', async () => {
server.use(http.get('/api/admin/storage', () => HttpResponse.json(runningState())));
const admin = await mount();
const before = admin();
// No await on a render: refreshState resolving is the only synchronisation
// point, and the ref must already carry the new world at that instant —
// that is the whole point of it, since a deferred commit can run an effect
// whose captured `state` is several responses behind.
await before.refreshState();
expect(before.storageBusy()).toBe(true);
await waitFor(() => expect(admin().state!.migrations).toHaveLength(1));
});
it('FE-ADMIN-STOR-050: a failed refresh leaves storageBusy() on the last world that was actually applied', async () => {
server.use(http.get('/api/admin/storage', () => HttpResponse.json(runningState())));
const admin = await mount();
expect(admin().storageBusy()).toBe(true);
server.use(http.get('/api/admin/storage', () => HttpResponse.error()));
// A transient GET failure must not read as "the server went idle" — that
// is the one answer that would let a second migration start.
await expect(admin().refreshState()).rejects.toThrow();
expect(admin().storageBusy()).toBe(true);
});
});
@@ -16,6 +16,20 @@ function isConflictError(err: unknown): boolean {
export interface StorageAdmin {
state: StorageAdminState | null
/**
* Whether the server is already busy with a storage job: a migration or a
* backfill, since its one-job-at-a-time rule spans both. Answered off the
* last response rather than off `state`, so it is current the instant that
* response lands rather than on the commit that renders it.
*
* That difference is the whole point. An effect re-run by a DEFERRED commit
* still closes over the `state` of the render it belongs to, which under load
* is several responses old by the time it runs, and the queue effect deciding
* on that closure POSTed a second migration on top of a running one. Anything
* deciding whether to fire a request asks this; anything rendering reads
* `state`. Never called during render.
*/
storageBusy: () => boolean
/** The settings-owned document (the PUT body), with local edits layered in. Carries the version the draft was built at. */
draft: StorageConfigPut | null
dirty: boolean
@@ -85,6 +99,15 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
savingRef.current = saving
}, [saving])
// Written by every state application, at response time rather than at commit
// time, and read back through storageBusy(). See the interface doc.
const stateRef = useRef<StorageAdminState | null>(null)
const storageBusy = useCallback((): boolean => {
const live = stateRef.current
if (!live) return false
return live.migrations.some((m) => m.status === 'running') || live.backfills.some((b) => b.status === 'running')
}, [])
// Bumped by every deliberate state application (initial load, save
// success). A refreshState() call captures the seq before its GET and
// drops the response if the seq moved on — i.e. a save applied its own
@@ -93,6 +116,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
const applyState = useCallback((next: StorageAdminState) => {
stateSeq.current += 1
stateRef.current = next
setState(next)
setDraftState(settingsDocumentOf(next))
setDirty(false)
@@ -145,6 +169,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
const seq = stateSeq.current
const next = await adminApi.getStorage()
if (stateSeq.current !== seq || savingRef.current) return
stateRef.current = next
setState(next)
if (!dirtyRef.current) setDraftState(settingsDocumentOf(next))
}, [])
@@ -317,7 +342,14 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
const refreshStats = useCallback(async (): Promise<string | null> => {
try {
const usage = await adminApi.refreshStorageStats()
setState((prev) => (prev ? { ...prev, usage } : prev))
// Off stateRef rather than a functional updater, so the ref and the
// rendered state stay one world: every writer here sets both.
const prev = stateRef.current
if (prev) {
const next = { ...prev, usage }
stateRef.current = next
setState(next)
}
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
@@ -326,6 +358,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
return {
state,
storageBusy,
draft,
dirty,
loading,
@@ -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,4 +1,4 @@
// FE-W4BGT-001 to FE-W4BGT-020
// FE-W4BGT-001 to FE-W4BGT-022
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, act, waitFor } from '@testing-library/react'
import { render, fireEvent } from '../../../tests/helpers/render'
@@ -185,6 +185,37 @@ describe('BackgroundTasksWidget — rehydrate', () => {
})
})
describe('BackgroundTasksWidget — poll backstop', () => {
it('FE-W4BGT-021: a job the server drops mid-poll ends as an error instead of spinning on', async () => {
vi.useFakeTimers()
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus)
.mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never)
.mockRejectedValue({ response: { status: 404 } })
render(<BackgroundTasksWidget />)
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('error')
// The file parsed fine — blaming it would send the user looking for a problem
// that isn't there.
expect(useBackgroundTasksStore.getState().tasks[0].error).toBe('Unknown error')
})
it('FE-W4BGT-022: a transient poll failure leaves the job running', async () => {
vi.useFakeTimers()
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus)
.mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never)
.mockRejectedValue({ response: { status: 500 } })
render(<BackgroundTasksWidget />)
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('running')
})
})
describe('BackgroundTasksWidget — AI retry', () => {
const withFiles = () => task({
items: [], sourceFiles: [new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })],
@@ -111,11 +111,16 @@ export default function BackgroundTasksWidget() {
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch(() => {})
.catch((err: { response?: { status?: number } }) => {
// The server 404s a job once it has expired (or after a restart). Nothing is
// ever coming, so end the card instead of spinning and re-polling forever.
// Not the parse-failure message — the file was fine, the job is just gone.
if (err?.response?.status === 404) setError(task.id, task.tripId, t('common.unknownError'))
})
}
}, 5000)
return () => clearInterval(iv)
}, [tasks, setProgress, setDone, setError])
}, [tasks, setProgress, setDone, setError, t])
if (tasks.length === 0) return null
@@ -158,7 +163,7 @@ export default function BackgroundTasksWidget() {
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
) : task.items.length > 0 ? (
<div>
<button
<button type="button"
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
@@ -187,7 +192,7 @@ export default function BackgroundTasksWidget() {
)}
</div>
{aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && (
<button
<button type="button"
onClick={() => retryWithAi(task)}
disabled={retrying === task.id}
className="bg-surface-tertiary text-content"
@@ -206,7 +211,7 @@ export default function BackgroundTasksWidget() {
</div>
{task.status !== 'running' && (
<button
<button type="button"
onClick={() => dismiss(task.id)}
className="bg-transparent text-content-faint"
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
@@ -48,12 +48,12 @@ export function hexLighten(hex: string, amount: number): string {
const m = hex.replace('#', '').match(/.{2}/g)
if (!m || m.length !== 3) return hex
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
const [r, g, b] = m.map(x => parseInt(x, 16))
const [r, g, b] = m.map(x => Number.parseInt(x, 16))
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
}
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
if (v == null || isNaN(v)) return '-'
if (v == null || Number.isNaN(v)) return '-'
const d = currencyDecimals(cur)
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
}
@@ -75,7 +75,8 @@ export function splitColorFor(userId: number, order: number) {
}
export function colorForUserId(userId: number) {
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
const id = Number.isFinite(userId) ? Math.trunc(userId) : 0
return SPLIT_COLORS[(id - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
}
/**
@@ -32,7 +32,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('No budget created yet');
expect(await screen.findByText('No budget created yet')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
@@ -40,7 +40,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText(/Create categories and entries/i);
expect(await screen.findByText(/Create categories and entries/i)).toBeInTheDocument();
});
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
@@ -48,7 +48,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('Enter category name...');
expect(await screen.findByPlaceholderText('Enter category name...')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
@@ -57,7 +57,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Hotel Paris');
expect(await screen.findByText('Hotel Paris')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-005: renders category section header', async () => {
@@ -87,7 +87,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Budget');
expect(await screen.findByText('Budget')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
@@ -96,7 +96,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('CSV');
expect(await screen.findByText('CSV')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
@@ -105,7 +105,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('New Entry');
expect(await screen.findByPlaceholderText('New Entry')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-010: adding new item via form calls POST and shows item', async () => {
@@ -124,7 +124,7 @@ describe('BudgetPanel', () => {
await user.type(nameInput, 'Restaurant Dinner');
const addBtn = screen.getByTitle('Add Reservation');
await user.click(addBtn);
await screen.findByText('Restaurant Dinner');
expect(await screen.findByText('Restaurant Dinner')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
@@ -161,7 +161,7 @@ describe('BudgetPanel', () => {
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Hotel A');
await screen.findByText('Hotel B');
expect(await screen.findByText('Hotel B')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
@@ -183,7 +183,7 @@ describe('BudgetPanel', () => {
);
render(<BudgetPanel tripId={1} />);
// Component renders even in empty state
await screen.findByText('No budget created yet');
expect(await screen.findByText('No budget created yet')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
@@ -193,7 +193,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Misc');
expect(await screen.findByText('Misc')).toBeInTheDocument();
// Row exists - EUR formatting would appear in values
});
@@ -233,7 +233,7 @@ describe('BudgetPanel', () => {
render(<BudgetPanel tripId={1} />);
const nameInput = await screen.findByPlaceholderText('New Entry');
await user.type(nameInput, 'Pizza{Enter}');
await screen.findByText('Pizza');
expect(await screen.findByText('Pizza')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
@@ -241,7 +241,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} tripMembers={[]} />);
await screen.findByText('No budget created yet');
expect(await screen.findByText('No budget created yet')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
@@ -342,7 +342,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('Enter category name...');
expect(await screen.findByPlaceholderText('Enter category name...')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-028: creating a new category via input calls POST and adds a section', async () => {
@@ -356,7 +356,7 @@ describe('BudgetPanel', () => {
render(<BudgetPanel tripId={1} />);
const input = await screen.findByPlaceholderText('Enter category name...');
await user.type(input, 'Souvenirs{Enter}');
await screen.findByText('Souvenirs');
expect(await screen.findByText('Souvenirs')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-029: settlement section renders flows with usernames', async () => {
@@ -371,7 +371,7 @@ describe('BudgetPanel', () => {
{ user_id: 2, username: 'bob', balance: 10, avatar_url: null },
],
flows: [
{ from: { username: 'alice', avatar_url: null }, to: { username: 'bob', avatar_url: null }, amount: 10 },
{ from: { user_id: 1, username: 'alice', avatar_url: null }, to: { user_id: 2, username: 'bob', avatar_url: null }, amount: 10 },
],
})
)
@@ -387,7 +387,7 @@ describe('BudgetPanel', () => {
await user.click(settlementBtn);
// alice and bob should appear in balances section
await screen.findByText('alice');
await screen.findByText('bob');
expect(await screen.findByText('bob')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-030: per-person summary renders usernames', async () => {
@@ -408,7 +408,7 @@ describe('BudgetPanel', () => {
];
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Shared Dinner');
await screen.findByText('testuser');
expect(await screen.findByText('testuser')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
@@ -451,7 +451,7 @@ describe('BudgetPanel', () => {
render(<BudgetPanel tripId={1} />);
await screen.findByText('Train');
// expense_date is rendered as plain text in read-only mode
await screen.findByText('2025-06-15');
expect(await screen.findByText('2025-06-15')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
@@ -465,7 +465,7 @@ describe('BudgetPanel', () => {
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
],
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
flows: [{ from: { user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { user_id: 2, username: 'bob', avatar_url: null }, amount: 30 }]
})
),
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
+3 -3
View File
@@ -46,7 +46,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
<button type="button" onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
<Plus size={16} />
</button>
@@ -87,7 +87,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
placeholder={t('budget.categoryName')}
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
<button type="button" onClick={handleAddCategory} disabled={!newCategoryName.trim()}
title={t('budget.addCategory')}
style={{
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
@@ -101,7 +101,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</button>
</div>
)}
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
@@ -19,7 +19,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
const handleAdd = () => {
if (!name.trim()) return
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
onAdd({ name: name.trim(), total_price: Number.parseFloat(String(price).replace(',', '.')) || 0, persons: Number.parseInt(persons) || null, days: Number.parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
setTimeout(() => nameRef.current?.focus(), 50)
}
@@ -57,7 +57,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
</td>
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
<button type="button" onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
<Plus size={14} />
@@ -586,7 +586,10 @@ describe('BudgetCategoryTable — member chips', () => {
it('FE-W4BCT-052: picking a member from either chip dropdown sets the item members', () => {
const { setBudgetItemMembers, container } = withMembers()
// The mobile stack under the name and the persons column both carry an editor.
const editors = container.querySelectorAll('td button')
// Assigned chips are buttons of their own, so the editors are the two that
// carry the picker icon.
const editors = Array.from(container.querySelectorAll('td button'))
.filter(b => b.querySelector('.lucide-pencil, .lucide-users'))
fireEvent.click(editors[0])
fireEvent.click(screen.getByText('bob'))
@@ -111,7 +111,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<>
<span style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{cat}</span>
{canEdit && (
<button onClick={() => setEditingCat({ name: cat, value: cat })}
<button type="button" onClick={() => setEditingCat({ name: cat, value: cat })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
<Pencil size={10} />
@@ -123,7 +123,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
{canEdit && (
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
<button type="button" onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
<Trash2 size={13} />
@@ -225,11 +225,11 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
readOnly={!canEdit}
/>
) : (
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? Number.parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
)}
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? Number.parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
@@ -246,7 +246,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
<td style={{ ...td, textAlign: 'center' }}>
{canEdit && (
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
<button type="button" onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
<Trash2 size={14} />
@@ -23,7 +23,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
const save = () => {
setEditing(false)
let v: string | number | null = editValue
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
if (type === 'number') { const p = Number.parseFloat(String(editValue).replace(',', '.')); v = Number.isNaN(p) ? null : p }
if (v !== value) onSave(v)
}
@@ -45,14 +45,27 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
: (value || '')
// Editable cells are real buttons so they can be tabbed to and pressed. A
// read-only cell renders no control at all rather than a disabled one: it is
// plain text, and a disabled button would be announced as an unavailable
// control that never becomes available. Same split the member chips use.
const cellStyle: React.CSSProperties = {
padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
width: '100%', textAlign: 'left', ...style,
}
const content = display || placeholder || '-'
if (readOnly) return <div style={{ ...cellStyle, cursor: 'default' }}>{content}</div>
return (
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', ...style }}
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
{display || placeholder || '-'}
</div>
<button type="button"
onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
style={{ ...cellStyle, cursor: 'pointer' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}>
{content}
</button>
)
}
@@ -22,6 +22,16 @@ function setup(props: Partial<Parameters<typeof BudgetMemberChips>[0]> = {}) {
return { onSetMembers, onTogglePaid, ...utils }
}
/**
* The member picker trigger. A chip that can be clicked is a real button too,
* so it is the icon that tells the trigger apart from the chips before it.
*/
function picker(): HTMLElement {
const trigger = screen.getAllByRole('button').find(b => b.querySelector('.lucide-pencil, .lucide-users'))
if (!trigger) throw new Error('no picker trigger rendered')
return trigger
}
describe('ChipWithTooltip', () => {
it('FE-W4BMC-001: falls back to the uppercased initial', () => {
const { container } = render(<ChipWithTooltip label="ada" avatarUrl={null} />)
@@ -76,7 +86,7 @@ describe('BudgetMemberChips', () => {
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('B')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
expect(picker()).toBeInTheDocument()
})
it('FE-W4BMC-007: uses the people icon while nobody is assigned and the pencil afterwards', () => {
@@ -114,9 +124,9 @@ describe('BudgetMemberChips', () => {
it('FE-W4BMC-011: the picker lists every trip member and marks the assigned ones', () => {
setup()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(picker())
const rows = screen.getAllByRole('button').slice(1)
const rows = screen.getAllByRole('button').slice(2)
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveTextContent('ada')
expect(rows[0].querySelector('.lucide-check')).not.toBeNull()
@@ -127,52 +137,52 @@ describe('BudgetMemberChips', () => {
it('FE-W4BMC-012: picking an unassigned member adds them', () => {
const { onSetMembers } = setup()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(picker())
fireEvent.click(screen.getAllByRole('button')[2])
fireEvent.click(screen.getAllByRole('button')[3])
expect(onSetMembers).toHaveBeenCalledWith([1, 2])
})
it('FE-W4BMC-013: picking an assigned member removes them', () => {
const { onSetMembers } = setup()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(picker())
fireEvent.click(screen.getAllByRole('button')[1])
fireEvent.click(screen.getAllByRole('button')[2])
expect(onSetMembers).toHaveBeenCalledWith([])
})
it('FE-W4BMC-014: a mousedown outside closes the picker, inside keeps it', () => {
setup()
const trigger = screen.getByRole('button')
const trigger = picker()
fireEvent.click(trigger)
fireEvent.mouseDown(screen.getAllByRole('button')[1])
expect(screen.getAllByRole('button')).toHaveLength(3)
fireEvent.mouseDown(screen.getAllByRole('button')[2])
expect(screen.getAllByRole('button')).toHaveLength(4)
fireEvent.mouseDown(trigger)
expect(screen.getAllByRole('button')).toHaveLength(3)
expect(screen.getAllByRole('button')).toHaveLength(4)
fireEvent.mouseDown(document.body)
expect(screen.getAllByRole('button')).toHaveLength(1)
expect(screen.getAllByRole('button')).toHaveLength(2)
})
it('FE-W4BMC-015: the trigger toggles the picker closed again', () => {
setup()
const trigger = screen.getByRole('button')
const trigger = picker()
fireEvent.click(trigger)
expect(screen.getAllByRole('button')).toHaveLength(3)
expect(screen.getAllByRole('button')).toHaveLength(4)
fireEvent.click(trigger)
expect(screen.getAllByRole('button')).toHaveLength(1)
expect(screen.getAllByRole('button')).toHaveLength(2)
})
it('FE-W4BMC-016: the non-compact variant renders larger chips', () => {
setup({ compact: false })
expect(screen.getByText('A')).toHaveStyle({ width: '30px' })
expect((screen.getByRole('button') as HTMLElement).style.width).toBe('28px')
expect(picker().style.width).toBe('28px')
})
})
@@ -22,7 +22,7 @@ interface ChipWithTooltipProps {
export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef<HTMLDivElement>(null)
const ref = useRef<HTMLElement | null>(null)
const onEnter = () => {
if (ref.current) {
@@ -35,22 +35,34 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }:
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
// Identical for both shells — a clickable chip is a real button so it can be
// tabbed to and pressed, a decorative one stays a plain div.
const chipStyle = {
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
transition: 'border-color 0.15s, background 0.15s',
padding: 0, fontFamily: 'inherit',
}
const inner = avatarUrl
? <img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: label?.[0]?.toUpperCase()
return (
<>
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
onClick={onClick}
style={{
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
transition: 'border-color 0.15s, background 0.15s',
}}>
{avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: label?.[0]?.toUpperCase()
}
</div>
{onClick ? (
<button type="button" ref={el => { ref.current = el }} aria-label={label}
onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
onClick={onClick} style={chipStyle}>
{inner}
</button>
) : (
<div ref={el => { ref.current = el }} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
style={chipStyle}>
{inner}
</div>
)}
{hover && createPortal(
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
@@ -131,7 +143,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
/>
))}
{!readOnly && (
<button ref={btnRef} onClick={openDropdown}
<button type="button" ref={btnRef} onClick={openDropdown}
style={{
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -149,7 +161,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
{tripMembers.map(tm => {
const isActive = memberIds.includes(tm.id)
return (
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
<button type="button" key={tm.id} onClick={() => toggleMember(tm.id)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left',
@@ -163,7 +175,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}>
{tm.avatar_url
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
? <img src={tm.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: tm.username?.[0]?.toUpperCase()
}
</div>
@@ -15,7 +15,7 @@ export default function RingAvatar({ userId, username, avatarUrl, size = 34, inn
overflow: 'hidden',
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
}}>
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
{avatarUrl ? <img src={avatarUrl} alt={username || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
</div>
</div>
)
@@ -75,7 +75,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
{/* Settlement dropdown inside the total card */}
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<button onClick={() => setSettlementOpen(v => !v)} style={{
<button type="button" onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: theme.sub, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, letterSpacing: 0.5,
@@ -83,7 +83,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')}
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
{/* Decoration inside the settlement toggle: the hint hangs off
hover, and the click only keeps the toggle from firing. A
role of its own would give the toggle a second name and a
nested control, so it declares itself presentational. */}
<span style={{ display: 'flex', cursor: 'help' }}
role="presentation"
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
onClick={e => e.stopPropagation()}
@@ -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)
@@ -22,7 +36,7 @@ export function splitCents(amount: number, n: number): number[] {
/** Sum the amounts of the selected payers. */
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
return [...ids].reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
}
/** True when the payer amounts add up to the expense total, to the cent. */
@@ -45,7 +59,7 @@ export function rebalancePayers(
if (free.length === 0) return amounts
const pinnedSum = all
.filter(id => pinned.has(id))
.reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
.reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
const shares = splitCents(total - pinnedSum, free.length)
const next = { ...amounts }
free.forEach((id, i) => { next[id] = shares[i] ? shares[i].toFixed(2) : '' })
@@ -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)
@@ -153,7 +174,7 @@ export function calculateTicketShares(items: TicketItem[]): { shares: Record<num
const everyone = [...new Set(items.flatMap(i => [...i.participants]))].sort((a, b) => a - b)
for (const item of items) {
const priceNum = parseFloat(item.price) || 0
const priceNum = Number.parseFloat(item.price) || 0
const priceCents = Math.round(priceNum * 100)
totalCents += priceCents
+371 -10
View File
@@ -10,7 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'
import type { BudgetItem } from '../../types'
import CostsPanel, { ExpenseModal } from './CostsPanel'
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
@@ -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())
@@ -594,7 +618,7 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByDisplayValue('100,00')).toBeDisabled()
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText('Individual shares')).toBeInTheDocument()
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
@@ -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()
})
})
@@ -916,6 +941,50 @@ describe('CostsPanel — settle up', () => {
delete window.__addToast
})
it('FE-W5COSTS-059: a settle-up that fails midway still reloads the transfers that went through', async () => {
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
let posted = 0
let reads = 0
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => {
reads += 1
return HttpResponse.json({ balances: [], flows: posted > 0 ? [flows[1]] : flows, settlements: [] })
}),
http.post('/api/trips/1/budget/settlements', () => {
posted += 1
return posted === 1 ? HttpResponse.json({ settlement: { id: 1 } }) : HttpResponse.json({ error: 'no' }, { status: 500 })
}),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
const settleAll = await screen.findByRole('button', { name: 'Settle up' })
await waitFor(() => expect(settleAll).not.toBeDisabled())
const readsBefore = reads
fireEvent.click(settleAll)
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
// The failed second transfer must not swallow the refresh: bob's 45 € was
// recorded, and leaving it listed invites a second, doubled settle-up.
await waitFor(() => expect(reads).toBeGreaterThan(readsBefore))
expect(posted).toBe(2)
delete window.__addToast
})
it('FE-W5COSTS-060: a settlement read that fails says so instead of claiming everyone is square', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ error: 'no' }, { status: 500 })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Balances')
// Settle up and Balances both hang off the one failed request.
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(2))
expect(screen.queryByText("Everyone's square")).toBeNull()
})
it('FE-W5COSTS-013: the "you owe" card lists who I still have to pay', async () => {
mount([], { flows: [{ from: { user_id: 1, username: 'alice' }, to: { user_id: 2, username: 'bob' }, amount: 20 }] })
@@ -1256,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 }))
@@ -1386,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())
@@ -1394,6 +1465,38 @@ describe('CostsPanel — expense modal', () => {
expect(onSaved).toHaveBeenCalled()
})
// The mobile sheet already dates a new expense by the traveller's own clock;
// the desktop modal filed it under the UTC day, so the same expense entered
// late in Tokyo or early in Los Angeles landed on a different day per surface.
it('FE-W5COSTS-065: a new expense is dated by the local calendar day, not the UTC one', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
// A wall-clock time on whichever side of midnight puts the runner's UTC date
// on a different day. On UTC itself the two spellings agree and there is
// nothing here to catch.
const behindUtc = new Date(2026, 7, 12).getTimezoneOffset() > 0
vi.setSystemTime(new Date(2026, 7, 12, behindUtc ? 23 : 1, 30, 0))
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
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: dinner() })
}))
render(
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
prefill={{ name: 'Ramen', category: 'food', amount: 18 }}
onClose={() => {}} onSaved={vi.fn()} />
)
await user.click(screen.getByRole('button', { name: 'Add expense' }))
try {
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.expense_date).toBe('2026-08-12')
} finally {
vi.useRealTimers()
}
})
it('FE-W5COSTS-038b: a prefill from a place links the expense to that place (#1298)', async () => {
const user = userEvent.setup()
let posted: Record<string, unknown> | null = null
@@ -1447,6 +1550,26 @@ describe('CostsPanel — remaining paths', () => {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
})
it('FE-W5COSTS-061: the CSV export neutralises names a spreadsheet would run as a formula', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR', title: 'Rome' }) })
let exported: Blob | null = null
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
mount([
expense({ id: 204, name: '=HYPERLINK("http://evil","click")', category: 'food', total_price: 12, expense_date: '2025-06-15', note: '@SUM(A1:A9)' }),
expense({ id: 205, name: '-5 refund', category: 'misc', total_price: 5, expense_date: '2025-06-16', note: null }),
])
await screen.findByText('-5 refund')
fireEvent.click(screen.getByTitle('Export CSV'))
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
expect(lines[1]).toBe('06/15/2025;"\'=HYPERLINK(""http://evil"",""click"")";Food & drink;12.00;EUR;12.00;\'@SUM(A1:A9)')
expect(lines[2]).toBe('06/16/2025;\'-5 refund;Other;5.00;EUR;5.00;')
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
})
it('FE-W5COSTS-043: deleting an expense drops it from the ledger', async () => {
let deleted = false
server.use(http.delete('/api/trips/1/budget/101', () => { deleted = true; return HttpResponse.json({ success: true }) }))
@@ -1544,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)
})
@@ -1615,7 +1739,7 @@ describe('CostsPanel — remaining paths', () => {
await user.click(bob)
await user.click(bob)
// 10.99 across two people leaves the odd cent with the lower user id.
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText('Individual shares')).toBeInTheDocument()
expect(screen.getByText('€5.50')).toBeInTheDocument()
expect(screen.getByText('€5.49')).toBeInTheDocument()
})
@@ -1741,6 +1865,21 @@ describe('CostsPanel — mobile extras', () => {
expect(await screen.findByPlaceholderText('0.00')).toBeInTheDocument()
})
it('FE-W5COSTS-062: the mobile search box keeps the caret across keystrokes', async () => {
const user = userEvent.setup()
mount([dinner(), taxi()])
await screen.findByText('Taxi')
const box = screen.getByPlaceholderText('Search expenses…')
await user.type(box, 'din')
// The whole word has to land: if the body is remounted per keystroke the input
// loses focus and everything after the first character goes nowhere.
expect((box as HTMLInputElement).value).toBe('din')
expect(document.activeElement).toBe(box)
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
})
it('FE-W5COSTS-054: an unknown currency degrades to a plain amount on mobile too', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XX' }) })
@@ -1751,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({})
@@ -1774,3 +2080,58 @@ describe('CostsPanel — split maths', () => {
expect(calculateTicketShares(items)).toEqual({ shares: { 1: 7.5, 2: 7.5 }, total: 15 })
})
})
// The reworked expense modal shipped with English written straight into the JSX,
// so the receipt panel and the split summary stayed English on a German phone
// while everything around them translated.
describe('CostsPanel — expense modal in another language', () => {
beforeEach(() => {
seedAlice()
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de', default_currency: 'EUR' }) })
clearExchangeRateCache()
})
afterEach(clearExchangeRateCache)
const openModal = () => render(
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
onClose={() => {}} onSaved={vi.fn()} />
)
it('FE-W5COSTS-063: the receipt panel is translated', async () => {
const user = userEvent.setup()
openModal()
// The locale bundle is fetched, so the first paint is still English.
await user.click(await screen.findByRole('button', { name: 'Beleg' }))
await user.click(screen.getByRole('button', { name: /Artikel hinzufügen/ }))
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument()
expect(screen.getByText('Aufteilen auf:')).toBeInTheDocument()
expect(screen.getByText('Anteil pro Person')).toBeInTheDocument()
expect(screen.queryByText('Individual shares')).not.toBeInTheDocument()
})
it('FE-W5COSTS-064: the split summary and the excluded marker are translated', async () => {
const user = userEvent.setup()
openModal()
await user.type(await screen.findByPlaceholderText('0,00'), '90')
await user.click(screen.getByRole('button', { name: 'Individuell' }))
// Nothing entered yet, so the whole 90 is still unaccounted for.
expect(screen.getByText('Summe der Anteile: €0.00 von €90.00 (es fehlen €90.00)')).toBeInTheDocument()
const shares = screen.getAllByPlaceholderText('45,00')
await user.type(shares[0], '90')
expect(screen.getByText('Aufteilung passt zur Summe')).toBeInTheDocument()
await user.type(shares[1], '10')
expect(screen.getByText('Summe der Anteile: €100.00 von €90.00 (€10.00 zu viel)')).toBeInTheDocument()
// Back to the equal split: a member taken out of it is marked, not dropped.
await user.click(screen.getByRole('button', { name: 'Gleichmäßig' }))
await user.click(screen.getByRole('button', { name: /bob/i }))
expect(screen.getByText('Nicht dabei')).toBeInTheDocument()
expect(screen.queryByText('Excluded')).not.toBeInTheDocument()
})
})
+135 -75
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
@@ -10,13 +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'
@@ -74,6 +75,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const { convert } = useExchangeRates(base)
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
const [settlement, setSettlement] = useState<SettlementData | null>(null)
// A failed settlement read leaves `settlement` null, which the empty views would
// otherwise present as "everyone is square", a balance claim we cannot make.
const [settlementError, setSettlementError] = useState(false)
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
const [search, setSearch] = useState('')
const [catFilter, setCatFilter] = useState('') // '' = all categories
@@ -99,7 +103,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
const loadSettlement = useCallback(() => {
budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {})
budgetApi.settlement(tripId, base)
.then(s => { setSettlement(s); setSettlementError(false) })
.catch(() => setSettlementError(true))
}, [tripId, base])
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
@@ -117,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) {
@@ -127,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)
@@ -219,8 +229,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
if (!flows.length) return
try {
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base })
loadSettlement()
} catch { toast.error(t('common.unknownError')) }
// Refresh even when one transfer failed: the ones created before it are real,
// and leaving them in the flow list invites a second, doubled settle-up.
finally { loadSettlement() }
}
const dateMeta = useMemo(() => {
@@ -241,7 +253,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// Costs rework (#1500). One row per expense, oldest first.
const handleExportCsv = () => {
const sep = ';'
const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
// A cell starting with =, +, -, @, TAB or CR is evaluated as a formula by Excel
// and Sheets, and the name/note columns are free text any trip member can write.
const esc = (v: unknown) => {
let s = String(v ?? '')
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s
return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s
}
const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } }
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note']
@@ -310,7 +328,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return (
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
{isMobile ? <MobileBody /> : (
{isMobile ? MobileBody() : (
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
{/* ── Header bar ── */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
@@ -334,12 +352,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={settleAll} disabled={!(settlement?.flows || []).length}
<button type="button" onClick={settleAll} disabled={!(settlement?.flows || []).length}
className="bg-surface-card border border-edge text-content disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
<Check size={16} /> {t('costs.settleUp')}
</button>
<button onClick={() => { setEditing(null); setModalOpen(true) }}
<button type="button" onClick={() => { setEditing(null); setModalOpen(true) }}
className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={16} /> {t('costs.addExpense')}
@@ -387,14 +405,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{filterControls}
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
{(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
<button type="button" key={f} onClick={() => setFilter(f)}
className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '6px 11px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.filter.' + f)}
</button>
))}
</div>
<button onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
className="bg-surface-input border border-edge text-content-muted disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>
<Download size={15} />
@@ -422,8 +440,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
? <Fragment key={'e' + en.e.id}>{ExpenseRow({ e: en.e })}</Fragment>
: <Fragment key={'s' + en.s.id}>{SettlementRow({ s: en.s })}</Fragment>)}
</div>
</div>
)
@@ -437,26 +455,26 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
{canEdit && (
<button onClick={() => setAddingPayment(true)}
<button type="button" onClick={() => setAddingPayment(true)}
className="text-content-muted bg-surface-secondary border border-edge"
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={13} /> {t('costs.addPayment')}
</button>
)}
</div>
<SettleFlows />
{SettleFlows()}
</div>
{/* balances */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
<BalancesList balances={settlement?.balances || []} />
{BalancesList({ balances: settlement?.balances || [] })}
</div>
{/* by category */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
<CategoryBreakdown />
{CategoryBreakdown()}
</div>
</div>
</div>
@@ -520,8 +538,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
)
// Settle-up and Balances both come from the one settlement request, so they
// share the notice that says the numbers are missing rather than zero.
function loadFailed() {
return <div className="text-content-muted" style={{ textAlign: 'center', padding: '14px 8px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('common.unknownError')}</div>
}
// ── shared settle-flow list ──────────────────────────────────────────────
function SettleFlows() {
if (settlementError) return loadFailed()
const flows = settlement?.flows || []
if (flows.length === 0) return (
<div style={{ textAlign: 'center', padding: '14px 8px' }}>
@@ -539,7 +564,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(f.amount)}</span>
{canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
{canEdit && <button type="button" onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
</div>
</div>
))}
@@ -548,6 +573,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
// ── mobile layout (Budget1Mobile.html): single flat column, total card on top ──
//
// Called as a plain function — `{MobileBody()}`, not `<MobileBody />` — and the
// same goes for ExpenseRow, SettleFlows, BalancesList and CategoryBreakdown.
// A component declared inside this one is a fresh component type on every
// render, so React tears the old subtree down and mounts a new one instead of
// updating it; the search box then loses focus after every keystroke. Calling
// them keeps the markup inline where it always was. Do not "tidy" these back
// into elements.
function MobileBody() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
@@ -560,7 +593,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
</div>
{canEdit && (
<button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<button type="button" onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={17} /> {t('costs.addExpense')}
</button>
)}
@@ -599,17 +632,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
{canEdit && (
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
<button type="button" onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
)}
</div>
<SettleFlows />
{SettleFlows()}
</div>
{/* Expenses */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
<button onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
className="bg-surface-card border border-edge text-content-muted disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>
<Download size={15} />
@@ -621,7 +654,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
{(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
<button type="button" key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
@@ -637,8 +670,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!dayFilter && <div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
? <Fragment key={'e' + en.e.id}>{ExpenseRow({ e: en.e })}</Fragment>
: <Fragment key={'s' + en.s.id}>{SettlementRow({ s: en.s })}</Fragment>)}</div>
</div>
)
})}
@@ -647,13 +680,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* Balances */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
<BalancesList balances={settlement?.balances || []} />
{BalancesList({ balances: settlement?.balances || [] })}
</div>
{/* By category */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
<CategoryBreakdown />
{CategoryBreakdown()}
</div>
</div>
)
@@ -676,17 +709,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
return (
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
<button title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
<button title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
<button type="button" title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
<button type="button" title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
</div>
)
}
// Called, not rendered — see MobileBody: an element here would remount the
// list on every keystroke in the search box.
function ExpenseRow({ e }: { e: BudgetItem }) {
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)
@@ -775,7 +810,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
</div>
</div>
{canEdit && <RowActions onEdit={() => { setEditing(e); setModalOpen(true) }} onDelete={() => handleDelete(e.id)} deleteLabel={t('common.delete')} />}
{canEdit && RowActions({ onEdit: () => { setEditing(e); setModalOpen(true) }, onDelete: () => handleDelete(e.id), deleteLabel: t('common.delete') })}
</div>
)
}
@@ -808,8 +843,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
{canEdit && (
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
<button type="button" title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
<button type="button" title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
</div>
)}
</div>
@@ -817,6 +852,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
if (settlementError) return loadFailed()
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
return (
@@ -846,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.
@@ -856,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 }} />
@@ -934,11 +973,13 @@ 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)
const amt = parseFloat(amount) || 0
const amt = Number.parseFloat(amount) || 0
const valid = amt > 0 && fromId !== toId
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
@@ -959,8 +1000,8 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
<button type="button" onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
@@ -1017,11 +1058,15 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [name, setName] = useState(editing?.name || prefill?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
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>>(() =>
@@ -1032,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]
@@ -1044,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
@@ -1069,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)
}
}
}
@@ -1084,9 +1130,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return calculateTicketShares(ticketItems)
}, [ticketItems])
const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
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)
@@ -1098,18 +1148,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const enteredSum = [...participants]
.filter(id => customAmounts[id])
.reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
const remaining = Math.max(0, totalNum - enteredSum)
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
// 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 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
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) => {
@@ -1164,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 }))
}
}
@@ -1187,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))
}
}
@@ -1231,8 +1286,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
// dropping the payer would store the entry with a total of 0.
const payerList = multiPayer
? [...payerIds]
.map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 }))
.filter(p => p.amount > 0)
.map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 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
@@ -1243,7 +1298,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const memberList = memberIds.map(id => ({
user_id: id,
amount: splitMode === 'custom'
? (parseFloat(customAmounts[id]) || 0)
? (Number.parseFloat(customAmounts[id]) || 0)
: splitMode === 'ticket'
? (ticketInfo.shares[id] || 0)
: null
@@ -1283,8 +1338,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size={isMobile ? '2xl' : '5xl'}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
<button type="button" onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
</div>
}>
{/* Two columns on desktop: what the expense was on the left, how it is
@@ -1302,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>
@@ -1321,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>
@@ -1338,7 +1394,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{COST_CATEGORY_LIST.map(c => {
const Icon = c.Icon; const on = cat === c.key
return (
<button key={c.key} onClick={() => setCat(c.key)}
<button type="button" key={c.key} onClick={() => setCat(c.key)}
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
<span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span>
@@ -1390,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"
@@ -1445,7 +1501,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 130px auto', gap: 8, alignItems: 'center' }}>
<input
type="text"
placeholder="Item name"
placeholder={t('costs.ticketItemName')}
value={item.name}
onChange={e => handleUpdateItemName(item.id, e.target.value)}
className="bg-surface-input border border-edge text-content"
@@ -1468,7 +1524,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>Splitting:</span>
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>{t('costs.ticketSplitting')}</span>
{people.map((p, pIdx) => {
const active = item.participants.has(p.id)
return (
@@ -1492,12 +1548,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
<button type="button" onClick={handleAddEmptyItem} className="border border-dashed border-edge text-content-muted" style={{ padding: '8px 12px', borderRadius: 10, background: 'none', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<Plus size={14} /> Add item
<Plus size={14} /> {t('costs.ticketAddItem')}
</button>
{ticketItems.length > 0 && (
<div className="bg-surface-secondary border border-edge" style={{ padding: 12, borderRadius: 10 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Individual Shares Summary</div>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>{t('costs.ticketShares')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{people.map(p => {
const share = ticketInfo.shares[p.id] || 0
@@ -1532,7 +1588,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
</span>
) : (
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>Excluded</span>
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>{t('costs.excluded')}</span>
)
) : (
on ? (
@@ -1557,9 +1613,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</span>
) : (
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
{customBalanced
? 'Split matches total'
: `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`}
{customBalanced
? t('costs.splitBalanced')
: 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),
})}
</span>
)}
</div>
@@ -53,7 +53,7 @@ describe('CollabChat', () => {
it('FE-COMP-CHAT-002: shows empty state when no messages', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
expect(await screen.findByText('Start the conversation')).toBeInTheDocument();
});
it('FE-COMP-CHAT-003: shows message input placeholder', async () => {
@@ -85,7 +85,7 @@ describe('CollabChat', () => {
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Hello world!');
expect(await screen.findByText('Hello world!')).toBeInTheDocument();
});
it('FE-COMP-CHAT-006: typing in input updates text field', async () => {
@@ -599,7 +599,7 @@ describe('CollabChat', () => {
await screen.findByText('New 100');
const loadMoreBtn = screen.getByRole('button', { name: /load/i });
await user.click(loadMoreBtn);
await screen.findByText('Older message');
expect(await screen.findByText('Older message')).toBeInTheDocument();
});
it('FE-COMP-CHAT-033: clicking delete on own message marks it as deleted', async () => {
+3 -3
View File
@@ -39,7 +39,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
</span>
<button onClick={() => setReplyTo(null)} style={{
<button type="button" onClick={() => setReplyTo(null)} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
display: 'flex', flexShrink: 0,
}}>
@@ -51,7 +51,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
<button type="button" ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -80,7 +80,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Send */}
{canEdit && (
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -46,7 +46,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
{/* Category tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
{Object.keys(EMOJI_CATEGORIES).map(c => (
<button key={c} onClick={() => setCat(c)} style={{
<button type="button" key={c} onClick={() => setCat(c)} style={{
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
background: cat === c ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
@@ -58,7 +58,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
{/* Emoji grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
<button key={i} onClick={() => onSelect(emoji)} style={{
<button type="button" key={i} onClick={() => onSelect(emoji)} style={{
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
padding: 2, transition: 'transform 0.1s',
@@ -2,7 +2,12 @@ import { useState, useEffect } from 'react'
import { collabApi } from '../../api/client'
/* ── Link Preview ── */
const previewCache = {}
// linkPreview is trip-scoped on the server, so the cache key has to be too.
// Capped as well: a long chat session would otherwise keep every preview it ever
// rendered for as long as the tab lives.
const previewCache = new Map()
const cacheKey = (tripId: number, url: string): string => `${tripId} ${url}`
const MAX_CACHED_PREVIEWS = 200
interface LinkPreviewProps {
url: string
@@ -12,17 +17,23 @@ interface LinkPreviewProps {
}
export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
const [data, setData] = useState(previewCache[url] || null)
const [loading, setLoading] = useState(!previewCache[url])
const [data, setData] = useState(previewCache.get(cacheKey(tripId, url)) || null)
const [loading, setLoading] = useState(!previewCache.has(cacheKey(tripId, url)))
useEffect(() => {
if (previewCache[url]) return
const key = cacheKey(tripId, url)
const cached = previewCache.get(key)
if (cached) { setData(cached); setLoading(false); return }
let current = true
collabApi.linkPreview(tripId, url).then(d => {
previewCache[url] = d
if (previewCache.size >= MAX_CACHED_PREVIEWS) previewCache.delete(previewCache.keys().next().value)
previewCache.set(key, d)
if (!current) return
setData(d)
setLoading(false)
if (d?.title || d?.description || d?.image) onLoad?.()
}).catch(() => setLoading(false))
}).catch(() => { if (current) setLoading(false) })
return () => { current = false }
}, [url, tripId])
if (loading || !data || (!data.title && !data.description && !data.image)) return null
@@ -24,7 +24,7 @@ export function ChatMessages(props: any) {
}}>
{hasMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
<button onClick={handleLoadMore} disabled={loadingMore} style={{
<button type="button" onClick={handleLoadMore} disabled={loadingMore} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
@@ -181,7 +181,7 @@ export function ChatMessages(props: any) {
transition: 'opacity .1s',
...(own ? { left: -6 } : { right: -6 }),
}}>
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
<button type="button" onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
@@ -193,7 +193,7 @@ export function ChatMessages(props: any) {
<Reply size={11} />
</button>
{own && canEdit && (
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
<button type="button" onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
@@ -18,7 +18,7 @@ export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadg
return (
<>
<button ref={ref} onClick={onReact}
<button type="button" ref={ref} onClick={onReact}
onMouseEnter={() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
@@ -31,7 +31,7 @@ export function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
}}>
{QUICK_REACTIONS.map(emoji => (
<button key={emoji} onClick={() => onReact(emoji)} style={{
<button type="button" key={emoji} onClick={() => onReact(emoji)} style={{
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
padding: 3, transition: 'transform 0.1s, background 0.1s',
@@ -1,4 +1,4 @@
// FE-W4CCS-001 to FE-W4CCS-016
// FE-W4CCS-001 to FE-W4CCS-019
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import type { ChatReaction } from './CollabChat.types'
@@ -11,6 +11,7 @@ import { TwemojiImg } from './CollabChatTwemojiImg'
import { ReactionBadge } from './CollabChatReactionBadge'
import { UserAvatar } from './CollabNotesUserAvatar'
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
import { LinkPreview } from './CollabChatLinkPreview'
function reaction(overrides: Partial<ChatReaction> = {}): ChatReaction {
return { emoji: '👍', count: 1, users: [{ id: 1, username: 'ada' }], ...overrides } as unknown as ChatReaction
@@ -147,13 +148,26 @@ describe('WebsiteThumbnail', () => {
expect(screen.getByText('example.com')).toBeInTheDocument()
})
it('FE-W4CCS-014: falls back to a link label for an unparseable url and a failing preview', async () => {
it('FE-W4CCS-014: keeps the domain chip when the preview request fails', async () => {
linkPreview.mockRejectedValue(new Error('blocked'))
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
render(<WebsiteThumbnail url="https://blocked.example/a" tripId={4} color="#000" />)
expect(screen.getByText('link')).toBeInTheDocument()
expect(screen.getByText('blocked.example')).toBeInTheDocument()
await waitFor(() => expect(linkPreview).toHaveBeenCalled())
expect(screen.getByRole('link')).toHaveAttribute('title', 'not a url')
expect(screen.getByRole('link')).toHaveAttribute('title', 'https://blocked.example/a')
})
it('FE-W4CCS-017: a url that is not http(s) is shown but neither linked nor previewed', async () => {
const { unmount } = render(<WebsiteThumbnail url="javascript:alert(1)" tripId={4} color="#000" />)
expect(screen.queryByRole('link')).toBeNull()
expect(screen.getByTitle('javascript:alert(1)')).toBeInTheDocument()
unmount()
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
expect(screen.getByText('link')).toBeInTheDocument()
expect(screen.queryByRole('link')).toBeNull()
expect(linkPreview).not.toHaveBeenCalled()
})
it('FE-W4CCS-015: caches per trip, because the preview endpoint is trip-scoped', async () => {
@@ -185,3 +199,34 @@ describe('WebsiteThumbnail', () => {
expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/b.png')
})
})
describe('LinkPreview', () => {
it('FE-W4CCS-018: caches per trip, because the preview endpoint is trip-scoped', async () => {
const shared = 'https://example.com/preview'
linkPreview.mockResolvedValue({ title: 'Trip 4', image: null })
const first = render(<LinkPreview url={shared} tripId={4} own={false} onLoad={undefined} />)
expect(await screen.findByText('Trip 4')).toBeInTheDocument()
first.unmount()
linkPreview.mockResolvedValue({ title: 'Trip 9', image: null })
render(<LinkPreview url={shared} tripId={9} own={false} onLoad={undefined} />)
expect(await screen.findByText('Trip 9')).toBeInTheDocument()
expect(linkPreview).toHaveBeenCalledTimes(2)
expect(linkPreview).toHaveBeenLastCalledWith(9, shared)
})
it('FE-W4CCS-019: a preview that resolves after the url changed is not shown', async () => {
let resolveFirst: (v: { title: string; image: string | null }) => void = () => {}
linkPreview.mockImplementationOnce(() => new Promise(res => { resolveFirst = res }))
const { rerender } = render(<LinkPreview url="https://slow.example/a" tripId={4} own={false} onLoad={undefined} />)
linkPreview.mockResolvedValue({ title: 'Second', image: null })
rerender(<LinkPreview url="https://fast.example/b" tripId={4} own={false} onLoad={undefined} />)
expect(await screen.findByText('Second')).toBeInTheDocument()
resolveFirst({ title: 'First', image: null })
await waitFor(() => expect(screen.queryByText('First')).toBeNull())
expect(screen.getByText('Second')).toBeInTheDocument()
})
})
+184 -28
View File
@@ -47,7 +47,7 @@ describe('CollabNotes', () => {
it('FE-COMP-NOTES-002: shows empty state when no notes', async () => {
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
});
it('FE-COMP-NOTES-003: shows New Note button', async () => {
@@ -70,7 +70,7 @@ describe('CollabNotes', () => {
)
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Packing Tips');
expect(await screen.findByText('Packing Tips')).toBeInTheDocument();
});
it('FE-COMP-NOTES-005: clicking New Note opens modal', async () => {
@@ -79,7 +79,7 @@ describe('CollabNotes', () => {
await screen.findByText('No notes yet');
await user.click(screen.getByText('New Note'));
// Modal opens with a title input — placeholder is "Note title" (no ellipsis)
await screen.findByPlaceholderText('Note title');
expect(await screen.findByPlaceholderText('Note title')).toBeInTheDocument();
});
it('FE-COMP-NOTES-006: note title is shown in the grid', async () => {
@@ -96,7 +96,7 @@ describe('CollabNotes', () => {
)
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('My Checklist');
expect(await screen.findByText('My Checklist')).toBeInTheDocument();
});
it('FE-COMP-NOTES-007: multiple notes all render', async () => {
@@ -117,8 +117,11 @@ describe('CollabNotes', () => {
it('FE-COMP-NOTES-008: Notes title heading is shown', async () => {
render(<CollabNotes {...defaultProps} />);
// The loading panel renders its own copy of the heading and swaps the whole
// node out once the fetch settles, so wait for the loaded state first.
await screen.findByText('No notes yet');
// collab.notes.title = "Notes"
await screen.findByText('Notes');
expect(screen.getByText('Notes')).toBeInTheDocument();
});
it('FE-COMP-NOTES-009: create note calls POST API', async () => {
@@ -236,7 +239,7 @@ describe('CollabNotes', () => {
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Editable Note');
await user.click(screen.getByTitle('Edit'));
await screen.findByDisplayValue('Editable Note');
expect(await screen.findByDisplayValue('Editable Note')).toBeInTheDocument();
});
it('FE-COMP-NOTES-016: category filter hides notes from other categories', async () => {
@@ -271,6 +274,7 @@ describe('CollabNotes', () => {
const listener = calls[calls.length - 1][0];
act(() => {
listener({
tripId: 1,
type: 'collab:note:created',
note: {
id: 50, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null,
@@ -279,7 +283,7 @@ describe('CollabNotes', () => {
},
});
});
await screen.findByText('Live Note');
expect(await screen.findByText('Live Note')).toBeInTheDocument();
});
it('FE-COMP-NOTES-018: WebSocket collab:note:deleted event removes note', async () => {
@@ -301,7 +305,7 @@ describe('CollabNotes', () => {
const calls = (addListener as ReturnType<typeof vi.fn>).mock.calls;
const listener = calls[calls.length - 1][0];
act(() => {
listener({ type: 'collab:note:deleted', noteId: 7 });
listener({ tripId: 1, type: 'collab:note:deleted', noteId: 7 });
});
await waitFor(() => expect(screen.queryByText('WS Delete')).not.toBeInTheDocument());
});
@@ -402,7 +406,7 @@ describe('CollabNotes', () => {
await screen.findByText('No notes yet');
await user.click(screen.getByTitle('Manage Categories'));
// The modal header renders "Category Settings" or similar
await screen.findByText('Manage Categories', { selector: 'h3' });
expect(await screen.findByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
});
it('FE-COMP-NOTES-025: CategorySettingsModal shows no categories message when empty', async () => {
@@ -410,7 +414,7 @@ describe('CollabNotes', () => {
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('No categories yet');
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
});
it('FE-COMP-NOTES-026: CategorySettingsModal add new category', async () => {
@@ -425,7 +429,7 @@ describe('CollabNotes', () => {
const addBtn = newCatInput.nextElementSibling as HTMLElement;
await user.click(addBtn);
// "Transport" category appears in the modal
await screen.findByText('Transport');
expect(await screen.findByText('Transport')).toBeInTheDocument();
});
it('FE-COMP-NOTES-027: CategorySettingsModal close button dismisses it', async () => {
@@ -463,6 +467,7 @@ describe('CollabNotes', () => {
const listener = calls[calls.length - 1][0];
act(() => {
listener({
tripId: 1,
type: 'collab:note:updated',
note: {
id: 5, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null,
@@ -672,7 +677,7 @@ describe('CollabNotes', () => {
// Click the PDF badge to open FilePreviewPortal
await user.click(screen.getByText('PDF'));
// FilePreviewPortal renders the file name in the header
await screen.findByText('document.pdf');
expect(await screen.findByText('document.pdf')).toBeInTheDocument();
});
it('FE-COMP-NOTES-037: note with website shows website thumbnail component', async () => {
@@ -853,7 +858,7 @@ describe('CollabNotes', () => {
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('Manage Categories', { selector: 'h3' });
// Find the "Transport" category name span and click to edit
const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename');
const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename');
if (categoryNameSpan) {
await user.click(categoryNameSpan);
// Now an input with value "Transport" should appear
@@ -975,7 +980,7 @@ describe('CollabNotes', () => {
await screen.findByText('Manage Categories', { selector: 'h3' });
// Find and click the "OldCat" category name span to enter edit mode
const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename');
const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename');
if (oldCatSpan) {
await user.click(oldCatSpan);
const editInput = screen.getByDisplayValue('OldCat');
@@ -1270,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.
@@ -1322,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;
@@ -1346,7 +1359,7 @@ describe('CollabNotes details', () => {
it('FE-W5CNT-001: a corrupt category cache in localStorage is ignored', async () => {
localStorage.setItem('collab-cats-1', '{not json');
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
});
it('FE-W5CNT-002: a category without a stored colour falls back to the first palette entry', async () => {
@@ -1367,13 +1380,13 @@ describe('CollabNotes details', () => {
it('FE-W5CNT-004: notes served as a bare array are rendered', async () => {
serveNotes([buildNote({ title: 'Array note' })]);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Array note');
expect(await screen.findByText('Array note')).toBeInTheDocument();
});
it('FE-W5CNT-005: an empty payload yields an empty list', async () => {
serveNotes(null);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
});
it('FE-W5CNT-006: a failing load falls back to the empty state', async () => {
@@ -1381,7 +1394,7 @@ describe('CollabNotes details', () => {
http.get('/api/trips/1/collab/notes', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
});
it('FE-W5CNT-007: a WebSocket create for a note already in the list does not duplicate it', async () => {
@@ -1390,7 +1403,7 @@ describe('CollabNotes details', () => {
await screen.findByText('Already here');
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:note:created', note: buildNote({ id: 4, title: 'Already here' }) });
handler({ tripId: 1, type: 'collab:note:created', note: buildNote({ id: 4, title: 'Already here' }) });
});
expect(screen.getAllByText('Already here')).toHaveLength(1);
});
@@ -1403,7 +1416,7 @@ describe('CollabNotes details', () => {
await screen.findByText('Second');
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:note:updated', note: { id: 2, title: 'Second renamed' } });
handler({ tripId: 1, type: 'collab:note:updated', note: { id: 2, title: 'Second renamed' } });
});
expect(await screen.findByText('Second renamed')).toBeInTheDocument();
expect(screen.getByText('First')).toBeInTheDocument();
@@ -1414,9 +1427,9 @@ describe('CollabNotes details', () => {
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Doomed');
const handler = wsHandler();
await act(async () => { handler({ type: 'collab:note:deleted' }); });
await act(async () => { handler({ tripId: 1, type: 'collab:note:deleted' }); });
expect(screen.getByText('Doomed')).toBeInTheDocument();
await act(async () => { handler({ type: 'collab:note:deleted', id: 9 }); });
await act(async () => { handler({ tripId: 1, type: 'collab:note:deleted', id: 9 }); });
await waitFor(() => expect(screen.queryByText('Doomed')).not.toBeInTheDocument());
});
@@ -1432,7 +1445,7 @@ describe('CollabNotes details', () => {
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'Fresh note');
await user.click(screen.getByRole('button', { name: 'Create' }));
await screen.findByText('Fresh note');
expect(await screen.findByText('Fresh note')).toBeInTheDocument();
});
it('FE-W5CNT-011: an empty create response leaves the list untouched', async () => {
@@ -1762,10 +1775,13 @@ describe('CollabNotes details', () => {
if (!img) throw new Error('image attachment not rendered yet');
return img;
});
fireEvent.mouseEnter(image);
expect(image.style.transform).toBe('scale(1.06)');
fireEvent.mouseLeave(image);
expect(image.style.transform).toBe('scale(1)');
// The clickable thumbnail is a real button wrapped around the image, so the
// hover transform lands on that button.
const imageTile = image.closest('button') as HTMLElement;
fireEvent.mouseEnter(imageTile);
expect(imageTile.style.transform).toBe('scale(1.06)');
fireEvent.mouseLeave(imageTile);
expect(imageTile.style.transform).toBe('scale(1)');
fireEvent.click(image);
await waitFor(() => expect(screen.queryByText('Download itinerary.zip')).not.toBeInTheDocument());
});
@@ -1803,6 +1819,59 @@ describe('CollabNotes details', () => {
expect(screen.getByText('Older note')).toBeInTheDocument();
});
it('FE-W5CNT-030: a WebSocket event for another trip is ignored', async () => {
serveNotes({ notes: [] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
const handler = wsHandler();
await act(async () => {
handler({ tripId: 2, type: 'collab:note:created', note: buildNote({ id: 60, title: 'Other trip note' }) });
});
expect(screen.queryByText('Other trip note')).not.toBeInTheDocument();
});
it('FE-W5CNT-031: a rejected rename still renames the rest and re-reads the list', async () => {
const user = userEvent.setup();
const puts: string[] = [];
serveNotesSequence([
{ notes: [buildNote({ id: 1, title: 'First note', category: 'OldCat' }), buildNote({ id: 2, title: 'Second note', category: 'OldCat' })] },
{ notes: [buildNote({ id: 1, title: 'First note', category: 'OldCat' }), buildNote({ id: 2, title: 'Second note', category: 'NewCat' })] },
]);
server.use(
http.put('/api/trips/1/collab/notes/1', () => {
puts.push('1');
return new HttpResponse(null, { status: 500 });
}),
http.put('/api/trips/1/collab/notes/2', () => {
puts.push('2');
return HttpResponse.json({ note: buildNote({ id: 2, title: 'Second note', category: 'NewCat' }) });
}),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('First note');
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('Manage Categories', { selector: 'h3' });
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!;
await user.click(oldCat);
const editInput = screen.getByDisplayValue('OldCat');
await user.clear(editInput);
await user.type(editInput, 'NewCat');
await user.keyboard('{Enter}');
await user.click(screen.getByRole('button', { name: /^Save$/i }));
// The rejected note must not stop the second one, but the modal stays open —
// half a rename is not a saved rename.
await waitFor(() => expect(puts).toEqual(['1', '2']));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
// Re-read: the note the server refused is still shown under its old category.
const header = screen.getByText('Manage Categories', { selector: 'h3' }).parentElement!;
await user.click(within(header).getByRole('button'));
await waitFor(() => expect(screen.getAllByText('NewCat').length).toBeGreaterThan(0));
expect(screen.getAllByText('OldCat').length).toBeGreaterThan(0);
});
it('FE-W5CNT-029: a failing delete reports an error and keeps the note in the list', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 30, title: 'Stubborn note' })] });
@@ -1819,4 +1888,91 @@ describe('CollabNotes details', () => {
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByText('Stubborn note')).toBeInTheDocument();
});
it('FE-W5CNT-032: a rejected category rename keeps the settings modal open and reports once', async () => {
const user = userEvent.setup();
serveNotes({
notes: [
buildNote({ id: 1, title: 'First note', category: 'OldCat' }),
buildNote({ id: 2, title: 'Second note', category: 'OldCat' }),
],
});
server.use(
http.put('/api/trips/1/collab/notes/:id', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('First note');
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('Manage Categories', { selector: 'h3' });
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!;
await user.click(oldCat);
const editInput = screen.getByDisplayValue('OldCat');
await user.clear(editInput);
await user.type(editInput, 'NewCat');
await user.keyboard('{Enter}');
await screen.findByText('NewCat');
await user.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
// Nothing was renamed, so the modal must not act like it was saved…
expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
// …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);
});
});
+74 -25
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 {
@@ -89,6 +91,9 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
if (!tripId) return
const handler = (msg) => {
// The panel is not remounted on a trip change, so an event still in flight
// from the trip we just left must not land in this list.
if (String(msg?.tripId) !== String(tripId)) return
if (msg.type === 'collab:note:created' && msg.note) {
setNotes(prev => {
if (prev.some(n => n.id === msg.note.id)) return prev
@@ -145,12 +150,13 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
}
}, [tripId, toast, t])
const handleUpdateNote = useCallback(async (noteId, data) => {
const handleUpdateNote = useCallback(async (noteId, data, opts: { silent?: boolean } = {}) => {
let result
try {
result = await collabApi.updateNote(tripId, noteId, data)
} catch (err) {
toast.error(t('common.error'))
// A batch of writes reports once for the whole run instead of once per note.
if (!opts.silent) toast.error(t('common.error'))
throw err
}
const updated = result?.note || result
@@ -161,16 +167,28 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
}
}, [tripId, toast, t])
// A colour or a rename is N single-note writes; if one of them is rejected the
// rest still have to run, and the list has to be re-read so it stops showing a
// change the server never took. Reporting the failure is the caller's job.
const resyncNotes = useCallback(async () => {
try {
const fresh = await collabApi.getNotes(tripId)
setNotes(fresh?.notes || fresh || [])
} catch {}
}, [tripId])
const saveCategoryColors = useCallback(async (newMap) => {
let failed = 0
// Update notes with changed colors
for (const [cat, color] of Object.entries(newMap)) {
const notesInCat = notes.filter(n => n.category === cat)
if (notesInCat.length > 0 && categoryColors[cat] !== color) {
for (const n of notesInCat) {
await handleUpdateNote(n.id, { color })
try { await handleUpdateNote(n.id, { color }) } catch { failed++ }
}
}
}
if (failed > 0) await resyncNotes()
// Save all categories (including empty ones) to localStorage
const emptyCats = {}
for (const [cat, color] of Object.entries(newMap)) {
@@ -179,7 +197,23 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
}
}
saveEmptyCategories(emptyCats)
}, [categoryColors, notes, handleUpdateNote])
}, [categoryColors, notes, handleUpdateNote, resyncNotes])
const renameCategory = useCallback(async (oldName, newName) => {
// Update all notes with this category in DB
const toUpdate = notes.filter(n => n.category === oldName)
let failed = 0
for (const n of toUpdate) {
try { await handleUpdateNote(n.id, { category: newName }, { silent: true }) } catch { failed++ }
}
// The rest still had to run, but a partial rename is not a saved rename: the
// settings modal has to stay open on the rejection instead of closing on it.
if (failed > 0) {
await resyncNotes()
toast.error(t('common.error'))
throw new Error(`rename failed for ${failed} of ${toUpdate.length} notes`)
}
}, [notes, handleUpdateNote, resyncNotes, toast, t])
const handleEditSubmit = useCallback(async (data) => {
if (!editingNote) return
@@ -232,7 +266,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
notes, loading, showNewModal, setShowNewModal, editingNote, setEditingNote,
viewingNote, setViewingNote, previewFile, setPreviewFile, showSettings, setShowSettings,
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
handleCreateNote, handleUpdateNote, saveCategoryColors, renameCategory, handleEditSubmit,
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
pendingDeleteNoteId, setPendingDeleteNoteId,
}
@@ -271,13 +305,13 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
{t('collab.notes.title')}
</h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings')}
{canEdit && <button type="button" onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Settings size={14} />
</button>}
{canEdit && <button onClick={() => setShowNewModal(true)}
{canEdit && <button type="button" onClick={() => setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} />
{t('collab.notes.new')}
@@ -290,7 +324,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t }: NotesState) {
return (
<div style={{ display: 'flex', gap: 4, padding: '8px 12px 0', overflowX: 'auto', flexShrink: 0 }}>
<button
<button type="button"
onClick={() => setActiveCategory(null)}
style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
@@ -303,7 +337,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
{t('collab.notes.all')}
</button>
{categories.map(cat => (
<button
<button type="button"
key={cat}
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
style={{
@@ -360,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={{
@@ -371,7 +414,8 @@ function ViewNoteModal(S: NotesState) {
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000, padding: 16,
}}
onClick={() => setViewingNote(null)}
role="presentation"
onClick={e => { if (e.target === e.currentTarget) setViewingNote(null) }}
>
<div
style={{
@@ -380,7 +424,6 @@ function ViewNoteModal(S: NotesState) {
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}
onClick={e => e.stopPropagation()}
>
<div style={{
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
@@ -398,13 +441,13 @@ function ViewNoteModal(S: NotesState) {
)}
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
{canEdit && <button type="button" onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={16} />
</button>}
<button onClick={() => setViewingNote(null)}
<button type="button" onClick={() => setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -413,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>
@@ -430,17 +484,18 @@ function ViewNoteModal(S: NotesState) {
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
) : (
<div title={a.original_name} onClick={() => setPreviewFile(a)}
<button type="button" title={a.original_name} onClick={() => setPreviewFile(a)}
style={{
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
transition: 'transform 0.12s, box-shadow 0.12s',
border: 'none', padding: 0, fontFamily: 'inherit',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
</button>
)}
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
</div>
@@ -462,7 +517,7 @@ export default function CollabNotes(props: CollabNotesProps) {
loading, tripId, t, categories, categoryColors, getCategoryColor, notes,
viewingNote, showNewModal, editingNote, previewFile, showSettings,
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, renameCategory, handleUpdateNote,
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
} = S
@@ -511,13 +566,7 @@ export default function CollabNotes(props: CollabNotesProps) {
categories={categories}
categoryColors={categoryColors}
onSave={saveCategoryColors}
onRenameCategory={async (oldName, newName) => {
// Update all notes with this category in DB
const toUpdate = notes.filter(n => n.category === oldName)
for (const n of toUpdate) {
await handleUpdateNote(n.id, { category: newName })
}
}}
onRenameCategory={renameCategory}
t={t}
/>
)}
@@ -1,10 +1,22 @@
import { useState, useEffect } from 'react'
import { getAuthUrl } from '../../api/authUrl'
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLElement>; onMouseLeave?: React.MouseEventHandler<HTMLElement>; alt?: string }) {
const [authSrc, setAuthSrc] = useState('')
useEffect(() => {
getAuthUrl(src, 'download').then(setAuthSrc)
}, [src])
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
if (!authSrc) return null
// A clickable thumbnail gets a real button around the image so it is
// reachable by keyboard. The caller's style stays on the outer box, so the
// hover transform/transition still animates the element that carries them.
if (onClick) {
return (
<button type="button" onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
style={{ ...style, padding: 0, border: 'none', background: 'none', display: 'block' }}>
<img src={authSrc} alt={alt} style={{ ...style, width: '100%', height: '100%', display: 'block' }} />
</button>
)
}
return <img src={authSrc} alt={alt} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />
}
@@ -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'
@@ -80,26 +81,26 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
display: 'flex', gap: 2,
}}>
{note.content && (
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
<button type="button" onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Maximize2 size={10} />
</button>
)}
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
{canEdit && <button type="button" onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = color}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
</button>}
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
{canEdit && <button type="button" onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={10} />
</button>}
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
{canEdit && <button type="button" onClick={handleDelete} title={t('collab.notes.delete')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -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>
@@ -171,17 +172,18 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
) : (
<div key={a.id} title={a.original_name} onClick={() => onPreviewFile?.(a)}
<button type="button" key={a.id} title={a.original_name} aria-label={a.original_name} onClick={() => onPreviewFile?.(a)}
style={{
width: 48, height: 48, borderRadius: 8, cursor: 'pointer',
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
border: 'none', padding: 0, fontFamily: 'inherit',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
transition: 'transform 0.12s, box-shadow 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
</button>
)
})}
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
@@ -48,9 +48,14 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
}
const handleSave = async () => {
// Apply renames to notes in DB
for (const [oldName, newName] of Object.entries(renames)) {
if (oldName !== newName) await onRenameCategory(oldName, newName)
// Apply renames to notes in DB. A rejected rename keeps the modal open — closing
// it would look like the rename went through; the caller has already reported it.
try {
for (const [oldName, newName] of Object.entries(renames)) {
if (oldName !== newName) await onRenameCategory(oldName, newName)
}
} catch {
return
}
await onSave(localColors)
onClose()
@@ -64,17 +69,17 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT,
}} onClick={onClose}>
}} role="presentation" onClick={onClose}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 420,
maxHeight: '80vh', overflow: 'auto', border: '1px solid var(--border-faint)',
}} onClick={e => e.stopPropagation()}>
}} role="presentation" onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
{t('collab.notes.categorySettings') || 'Category Settings'}
</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
<X size={16} />
</button>
</div>
@@ -91,7 +96,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
{/* Color swatches */}
<div style={{ display: 'flex', gap: 4 }}>
{NOTE_COLORS.map(c => (
<button key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
<button type="button" key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
width: 20, height: 20, borderRadius: 6, background: c.value, border: 'none', cursor: 'pointer', padding: 0,
outline: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? '2px solid var(--text-primary)' : '2px solid transparent',
outlineOffset: 1, transition: 'transform 0.1s',
@@ -102,7 +107,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
{/* Category name — editable */}
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} />
{/* Delete */}
<button onClick={() => handleRemoveCategory(cat)} style={{
<button type="button" onClick={() => handleRemoveCategory(cat)} style={{
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 3, display: 'flex',
}}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
@@ -121,7 +126,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
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',
}} />
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
<button type="button" onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
border: 'none', borderRadius: 10, padding: '8px 14px', cursor: newCatName.trim() ? 'pointer' : 'default',
display: 'flex', alignItems: 'center', flexShrink: 0,
@@ -131,7 +136,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
</div>
{/* Save */}
<button onClick={handleSave} style={{
<button type="button" onClick={handleSave} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
}}>
@@ -25,10 +25,10 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) {
}
return (
<span onClick={() => { setValue(name); setEditing(true) }}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
<button type="button" onClick={() => { setValue(name); setEditing(true) }}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0', background: 'none', border: 'none', textAlign: 'left', fontFamily: 'inherit' }}
title="Click to rename">
{name}
</span>
</button>
)
}
@@ -28,10 +28,10 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
const openInNewTab = () => openFile(rawUrl).catch(() => {})
return createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} role="presentation" onClick={onClose}>
{isImage ? (
/* Image lightbox — floating controls */
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} role="presentation" onClick={e => e.stopPropagation()}>
{authUrl
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
: <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" />
@@ -39,30 +39,30 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
</div>
</div>
</div>
) : (
/* Document viewer — card with header */
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}>
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} role="presentation" onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
</div>
</div>
{(isPdf || isTxt) ? (
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download</button>
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download</button>
</p>
</object>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download {file.original_name}</button>
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download {file.original_name}</button>
</div>
)}
</div>
@@ -84,6 +84,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
}}
>
<form
role="presentation"
style={{
background: 'var(--bg-card)',
borderRadius: 16,
+1 -1
View File
@@ -170,7 +170,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{tabs.map(tab => {
const active = mobileTab === tab.id
return (
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
background: active ? 'var(--accent)' : 'transparent',
+190 -17
View File
@@ -12,7 +12,7 @@ vi.mock('../../api/websocket', () => ({
import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { http, HttpResponse, delay } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
@@ -55,7 +55,7 @@ beforeEach(() => {
describe('CollabPolls', () => {
it('FE-COMP-POLLS-001: renders empty state when no polls exist', async () => {
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
});
it('FE-COMP-POLLS-002: shows loading spinner initially', async () => {
@@ -79,7 +79,7 @@ describe('CollabPolls', () => {
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
expect(await screen.findByText('Best destination?')).toBeInTheDocument();
});
it('FE-COMP-POLLS-004: renders poll options', async () => {
@@ -108,7 +108,7 @@ describe('CollabPolls', () => {
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
// Modal has a question placeholder input
await screen.findByPlaceholderText(/what should we do/i);
expect(await screen.findByPlaceholderText(/what should we do/i)).toBeInTheDocument();
});
it('FE-COMP-POLLS-007: create modal requires question and at least 2 options to enable submit', async () => {
@@ -153,7 +153,7 @@ describe('CollabPolls', () => {
await user.type(optionInputs[1], 'Japanese');
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
await screen.findByText('Where to eat?');
expect(await screen.findByText('Where to eat?')).toBeInTheDocument();
});
it('FE-COMP-POLLS-009: voting on an option calls POST vote API', async () => {
@@ -188,7 +188,7 @@ describe('CollabPolls', () => {
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/closed/i);
expect(await screen.findByText(/closed/i)).toBeInTheDocument();
});
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
@@ -234,9 +234,9 @@ describe('CollabPolls', () => {
// Get the WS listener that was registered
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
listener({ tripId: 1, type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
await screen.findByText('Live poll?');
expect(await screen.findByText('Live poll?')).toBeInTheDocument();
});
it('FE-COMP-POLLS-014: WebSocket collab:poll:deleted event removes poll', async () => {
@@ -249,7 +249,7 @@ describe('CollabPolls', () => {
await screen.findByText('Best destination?');
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:deleted', pollId: 3 });
listener({ tripId: 1, type: 'collab:poll:deleted', pollId: 3 });
await waitFor(() =>
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
@@ -337,7 +337,7 @@ describe('CollabPolls details', () => {
it('FE-W5CPL-005: a poll served as a bare array is rendered', async () => {
servePolls([buildPoll({ question: 'Array shaped?' })]);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Array shaped?');
expect(await screen.findByText('Array shaped?')).toBeInTheDocument();
});
it('FE-W5CPL-006: options without a voters array count as zero votes', async () => {
@@ -484,13 +484,13 @@ describe('CollabPolls details', () => {
http.get('/api/trips/1/collab/polls', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
});
it('FE-W5CPL-028: a payload without a polls key yields an empty list', async () => {
servePolls({});
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
});
it('FE-W5CPL-015: a failing close shows an error and leaves the poll open', async () => {
@@ -658,8 +658,8 @@ describe('CollabPolls details', () => {
const handler = wsHandler();
await act(async () => {
handler({});
handler({ type: 'collab:poll:deleted' });
handler({ type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
handler({ tripId: 1, type: 'collab:poll:deleted' });
handler({ tripId: 1, type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
});
expect(screen.getAllByText('Best destination?')).toHaveLength(1);
});
@@ -671,22 +671,52 @@ describe('CollabPolls details', () => {
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
handler({ tripId: 1, type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
});
expect(await screen.findByText('Voted live')).toBeInTheDocument();
expect(screen.getByText('Other poll')).toBeInTheDocument();
await act(async () => {
handler({ type: 'collab:poll:closed', poll: { id: 41 } });
handler({ tripId: 1, type: 'collab:poll:closed', poll: { id: 41 } });
});
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
await act(async () => {
handler({ type: 'collab:poll:deleted', poll: { id: 42 } });
handler({ tripId: 1, type: 'collab:poll:deleted', poll: { id: 42 } });
});
await waitFor(() => expect(screen.queryByText('Other poll')).not.toBeInTheDocument());
});
it('FE-W5CPL-027: a WebSocket event for another trip is ignored', async () => {
servePolls({ polls: [] });
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab.polls.empty/i);
const handler = wsHandler();
await act(async () => {
handler({ tripId: 2, type: 'collab:poll:created', poll: buildPoll({ id: 88, question: 'Other trip poll?' }) });
});
expect(screen.queryByText('Other trip poll?')).not.toBeInTheDocument();
});
it('FE-W5CPL-028: a slow load for the trip we left does not overwrite the new one', async () => {
server.use(
http.get('/api/trips/1/collab/polls', async () => {
await delay(80);
return HttpResponse.json({ polls: [buildPoll({ id: 1, question: 'Trip one poll?' })] });
}),
http.get('/api/trips/2/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ id: 2, question: 'Trip two poll?' })] }),
),
);
const { rerender } = render(<CollabPolls {...defaultProps} />);
rerender(<CollabPolls tripId={2} currentUser={currentUser} />);
await screen.findByText('Trip two poll?');
await act(async () => { await delay(150); });
expect(screen.queryByText('Trip one poll?')).not.toBeInTheDocument();
expect(screen.getByText('Trip two poll?')).toBeInTheDocument();
});
it('FE-W5CPL-026: a poll with a live deadline starts the countdown ticker', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
@@ -702,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();
});
});
+68 -20
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()
@@ -88,8 +120,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
}
return createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={handleSubmit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
@@ -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>
)}
@@ -122,13 +155,13 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
{/* Multi choice toggle */}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<div onClick={() => setMultiChoice(!multiChoice)} style={{
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer',
<button type="button" role="switch" aria-checked={multiChoice} onClick={() => setMultiChoice(!multiChoice)} style={{
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer', border: 'none',
background: multiChoice ? '#007AFF' : 'var(--border-primary)', transition: 'background 0.2s',
display: 'flex', alignItems: 'center',
}}>
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
</div>
</button>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
</label>
@@ -174,7 +207,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
}}>
{voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
{voter.avatar_url ? <img src={voter.avatar_url} alt={voter.username || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
</div>
{hover && createPortal(
<div style={{
@@ -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 && (
@@ -250,14 +285,14 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{canEdit && (
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{!isClosed && (
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
<button type="button" onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Lock size={12} />
</button>
)}
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
<button type="button" onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -278,10 +313,10 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
return (
// React dispatches no mouse events on a disabled control, so the
// handlers below need no isClosed guard of their own.
<button key={idx} onClick={() => onVote(poll.id, idx)}
<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}
@@ -359,15 +397,25 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const [showForm, setShowForm] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
collabApi.getPolls(tripId).then(data => {
setPolls(Array.isArray(data) ? data : data.polls || [])
}).catch(() => {}).finally(() => setLoading(false))
if (!cancelled) setPolls(Array.isArray(data) ? data : data.polls || [])
}).catch(() => {
if (!cancelled) setPolls([])
}).finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [tripId])
// WebSocket
useEffect(() => {
const handler = (msg) => {
if (!msg?.type) return
// The panel is not remounted on a trip change, so an event still in flight
// from the trip we just left must not land in this list.
if (String(msg.tripId) !== String(tripId)) return
if (msg.type === 'collab:poll:created' && msg.poll) {
setPolls(prev => prev.some(p => p.id === msg.poll.id) ? prev : [msg.poll, ...prev])
}
@@ -384,7 +432,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
}
addListener(handler)
return () => removeListener(handler)
}, [])
}, [tripId])
const handleCreate = useCallback(async (data) => {
try {
@@ -455,7 +503,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{t('collab.polls.title')}
</h3>
{canEdit && (
<button onClick={() => setShowForm(true)} style={{
<button type="button" onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer',
@@ -3,21 +3,25 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import WhatsNextWidget from './WhatsNextWidget'
import { afterEach, beforeEach, describe, it, expect } from 'vitest'
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
// Dynamic date helpers
const today = new Date().toISOString().split('T')[0]
// Dynamic date helpers, in local calendar dates like the widget itself uses
function localDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const today = localDate(new Date())
function getFutureDate(daysAhead: number): string {
const d = new Date()
d.setDate(d.getDate() + daysAhead)
return d.toISOString().split('T')[0]
return localDate(d)
}
function getPastDate(daysBack: number): string {
const d = new Date()
d.setDate(d.getDate() - daysBack)
return d.toISOString().split('T')[0]
return localDate(d)
}
const tomorrow = getFutureDate(1)
@@ -258,6 +262,29 @@ describe('WhatsNextWidget', () => {
expect(screen.getByText('Lunch')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-016: keeps remaining events for the local day west of UTC after the UTC date rolls over', () => {
const prevTz = process.env.TZ
process.env.TZ = 'America/New_York'
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
// 20:00 in New York, already the next calendar day in UTC
vi.setSystemTime(new Date('2026-08-24T00:00:00Z'))
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: '2026-08-23', title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(80, { name: 'Late Show', place_time: '21:00' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('Late Show')).toBeInTheDocument()
expect(screen.getByText(/today/i)).toBeInTheDocument()
} finally {
vi.useRealTimers()
if (prevTz === undefined) delete process.env.TZ
else process.env.TZ = prevTz
}
})
it('FE-COMP-WHATSNEXT-015: today past-time event is excluded', () => {
// If it's not midnight, a past-time event today should not appear
const now = new Date()
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { MapPin, Clock, Users, Sparkles } from 'lucide-react'
import EmptyState from '../shared/EmptyState'
import { localToday } from '../Planner/today'
function formatTime(timeStr, is12h) {
if (!timeStr) return ''
@@ -19,9 +20,10 @@ function formatTime(timeStr, is12h) {
function formatDayLabel(date, t, locale) {
const now = new Date()
const nowDate = now.toISOString().split('T')[0]
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
// Day dates are plain calendar strings, so "today"/"tomorrow" have to be
// compared against the local calendar day, not the UTC one.
const nowDate = localToday(now)
const tomorrowDate = localToday(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1))
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
@@ -47,7 +49,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
const upcoming = useMemo(() => {
const now = new Date()
const nowDate = now.toISOString().split('T')[0]
const nowDate = localToday(now)
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
const items = []
@@ -177,7 +179,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
overflow: 'hidden', flexShrink: 0,
}}>
{p.avatar
? <img src={avatarSrc(p.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
? <img src={avatarSrc(p.avatar)!} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: p.username?.[0]?.toUpperCase()
}
</div>
@@ -97,8 +97,8 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const cleanName = name.trim()
if (!cleanName) return
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
const latNum = lat.trim() ? Number(lat) : NaN
const lngNum = lng.trim() ? Number(lng) : NaN
const latNum = lat.trim() ? Number(lat) : Number.NaN
const lngNum = lng.trim() ? Number(lng) : Number.NaN
setSaving(true)
try {
const res = await collectionsApi.savePlace({
@@ -135,7 +135,10 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text').trim()
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
// Same pairs as before, written so no two quantifiers can claim the same
// character. In the old form `\d+\.?\d*` and `\s*[,;\s]\s*` were both ambiguous,
// which backtracks in O(n^4): a pasted 2 kB of digits and spaces froze the tab.
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
}
const coordInputClass = 'w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent'

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